Compare commits

...
Author SHA1 Message Date
utkarsh240799andClaude Opus 4.6 9aff112a73 feat(mem0-ts): add LM Studio embedder and LLM support (fixes #4235)
Add LM Studio as a supported provider for both embeddings and LLM in
the TypeScript SDK, bringing it to parity with the Python SDK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:32 +05:30
8230a5dac7 fix: cast vector_distance to float in Redis search (#4377)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:34:46 +05:30
Saket AryanandGitHub 9864584c21 feat: add openclaw checks CI workflow (#4368) 2026-03-17 12:06:13 +05:30
Saket AryanandGitHub 9eea060db9 docs: fix mintlify build failing (#4363) 2026-03-16 23:02:54 +05:30
15218d4a7f chore: bump mem0-ts to 2.4.1, pyproject to 1.0.6, update changelog with bug fixes (#4361)
Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
2026-03-16 22:49:31 +05:30
KartikandGitHub 69001d7b1f chore(docs): adding skills.sh installation command in the readme. (#4350) 2026-03-16 22:05:15 +05:30
dhilip_binnyandGitHub 35fe30aabd fix: ensure JSON instruction in prompts for json_object response format (#3559) (#4271) 2026-03-16 21:57:57 +05:30
KartikandGitHub 11a7d8378c chore: update langchain dependencies to v1.0.0 (#4353) 2026-03-16 21:43:14 +05:30
KartikandGitHub b4b73deada fix(oss): OllamaLLM now respects configured url instead of always falling back to localhost (#4320) 2026-03-16 21:42:46 +05:30
bfe730aa38 fix(openclaw): add SQLite resilience for OSS mode initialization (#4337)
Co-authored-by: utkarsh240799 <utkarsh240799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:36:27 +05:30
KartikandGitHub 82d67430dd fix: remove destructive vector_store.reset() from delete_all() (#4349) 2026-03-16 20:55:29 +05:30
KartikandGitHub 8fcf2b0b29 fix: skip telemetry vector store init when MEM0_TELEMETRY is disabled (#4351) 2026-03-16 20:55:04 +05:30
Anisha MahuliandGitHub 2e5e290434 fix: key error when llm omits entities key tool call (#4313) 2026-03-16 20:52:26 +05:30
36 changed files with 1970 additions and 105 deletions
+100
View File
@@ -0,0 +1,100 @@
name: openclaw checks
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'openclaw/**'
- '.github/workflows/openclaw-checks.yml'
pull_request:
paths:
- 'openclaw/**'
- '.github/workflows/openclaw-checks.yml'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Type check
run: cd openclaw && pnpm exec tsc --noEmit
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22]
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Run tests with coverage
run: cd openclaw && pnpm exec vitest run --coverage
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
flags: openclaw
directory: openclaw/coverage
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
cache-dependency-path: openclaw/pnpm-lock.yaml
- name: Install dependencies
run: cd openclaw && pnpm install --frozen-lockfile
- name: Build
run: cd openclaw && pnpm build
- name: Verify dist output exists
run: |
test -f openclaw/dist/index.js || (echo "Build output missing: dist/index.js" && exit 1)
test -f openclaw/dist/index.d.ts || (echo "Build output missing: dist/index.d.ts" && exit 1)
+26
View File
@@ -7,6 +7,22 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-16" description="v1.0.6">
**Bug Fixes:**
- **Telemetry:** Fixed telemetry vector store initialization still running when `MEM0_TELEMETRY` is disabled (#4351)
- **Core:** Removed destructive `vector_store.reset()` call from `delete_all()` that was wiping the entire vector store instead of deleting only the target memories (#4349)
- **OSS:** `OllamaLLM` now respects the configured URL instead of always falling back to localhost (#4320)
- **Core:** Fixed `KeyError` when LLM omits the `entities` key in tool call response (#4313)
- **Prompts:** Ensured JSON instruction is included in prompts when using `json_object` response format (#4271)
- **Core:** Fixed incorrect database parameter handling (#3913)
**Dependencies:**
- Updated LangChain dependencies to v1.0.0 (#4353)
- Bumped protobuf dependency to 5.29.6 and extended upper bound to `<7.0.0` (#4326)
</Update>
<Update label="2026-03-03" description="v1.0.5">
- **Telemetry Fix**
- Fixed an issue where the PostHog client was initialized even after telemetry was disabled. Although events were not captured, the client was unnecessarily initialized.
@@ -729,6 +745,16 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-03-16" description="v2.4.1">
**Bug Fixes:**
- **Core:** Fixed code block content extraction — content inside code blocks is now properly extracted instead of being deleted (#4317)
**Improvements:**
- **Code Quality:** Fixed linting issues across the SDK (#4334)
</Update>
<Update label="2026-03-14" description="v2.4.0">
**Bug Fixes:**
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.4.0",
"version": "2.4.1",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -103,7 +103,7 @@
"@azure/search-documents": "^12.0.0",
"@cloudflare/workers-types": "^4.20250504.0",
"@google/genai": "^1.2.0",
"@langchain/core": "^0.3.44",
"@langchain/core": "^1.0.0",
"@mistralai/mistralai": "^1.5.2",
"@qdrant/js-client-rest": "1.13.0",
"@supabase/supabase-js": "^2.49.1",
+65 -45
View File
@@ -23,8 +23,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0(@modelcontextprotocol/sdk@1.12.1)(encoding@0.1.13)
"@langchain/core":
specifier: ^0.3.44
version: 0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
specifier: ^1.0.0
version: 1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
"@mistralai/mistralai":
specifier: ^1.5.2
version: 1.5.2(zod@3.24.2)
@@ -917,12 +917,12 @@ packages:
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
}
"@langchain/core@0.3.44":
"@langchain/core@1.1.32":
resolution:
{
integrity: sha512-3BsSFf7STvPPZyl2kMANgtVnCUvDdyP4k+koP+nY2Tczd5V+RFkuazIn/JOj/xxy/neZjr4PxFU4BFyF1aKXOA==,
integrity: sha512-ZZNiER5tceFXqZOghfrxNHzM60gcQL5XK/8Ow5+o4OuKHrP1p/RUQBDM9Y1nddi/VmKQj+ncaXXM5KovXTEGGQ==,
}
engines: { node: ">=18" }
engines: { node: ">=20" }
"@mistralai/mistralai@1.5.2":
resolution:
@@ -1214,6 +1214,12 @@ packages:
integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==,
}
"@standard-schema/spec@1.1.0":
resolution:
{
integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==,
}
"@supabase/auth-js@2.68.0":
resolution:
{
@@ -1383,12 +1389,6 @@ packages:
integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==,
}
"@types/retry@0.12.0":
resolution:
{
integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==,
}
"@types/stack-utils@2.0.3":
resolution:
{
@@ -1813,6 +1813,13 @@ packages:
}
engines: { node: ">=10" }
chalk@5.6.2:
resolution:
{
integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==,
}
engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 }
char-regex@1.0.2:
resolution:
{
@@ -3295,16 +3302,28 @@ packages:
integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==,
}
langsmith@0.3.15:
langsmith@0.5.10:
resolution:
{
integrity: sha512-cv3ebg0Hh0gRbl72cv/uzaZ+KOdfa2mGF1s74vmB2vlNVO/Ap/O9RYaHV+tpR8nwhGZ50R3ILnTOwSwGP+XQxw==,
integrity: sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A==,
}
peerDependencies:
"@opentelemetry/api": "*"
"@opentelemetry/exporter-trace-otlp-proto": "*"
"@opentelemetry/sdk-trace-base": "*"
openai: "*"
ws: ">=7"
peerDependenciesMeta:
"@opentelemetry/api":
optional: true
"@opentelemetry/exporter-trace-otlp-proto":
optional: true
"@opentelemetry/sdk-trace-base":
optional: true
openai:
optional: true
ws:
optional: true
leven@3.1.0:
resolution:
@@ -3794,13 +3813,6 @@ packages:
}
engines: { node: ">=8" }
p-retry@4.6.2:
resolution:
{
integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==,
}
engines: { node: ">=8" }
p-timeout@3.2.0:
resolution:
{
@@ -4271,13 +4283,6 @@ packages:
engines: { node: ">= 0.4" }
hasBin: true
retry@0.13.1:
resolution:
{
integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==,
}
engines: { node: ">= 4" }
reusify@1.1.0:
resolution:
{
@@ -4928,6 +4933,13 @@ packages:
}
hasBin: true
uuid@11.1.0:
resolution:
{
integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==,
}
hasBin: true
uuid@8.3.2:
resolution:
{
@@ -5143,6 +5155,12 @@ packages:
integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==,
}
zod@3.25.76:
resolution:
{
integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==,
}
snapshots:
"@ampproject/remapping@2.3.0":
dependencies:
@@ -5759,22 +5777,25 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.0
"@langchain/core@0.3.44(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))":
"@langchain/core@1.1.32(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)":
dependencies:
"@cfworker/json-schema": 4.1.1
"@standard-schema/spec": 1.1.0
ansi-styles: 5.2.0
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.19
langsmith: 0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))
langsmith: 0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1)
mustache: 4.2.0
p-queue: 6.6.2
p-retry: 4.6.2
uuid: 10.0.0
zod: 3.24.2
zod-to-json-schema: 3.24.5(zod@3.24.2)
uuid: 11.1.0
zod: 3.25.76
transitivePeerDependencies:
- "@opentelemetry/api"
- "@opentelemetry/exporter-trace-otlp-proto"
- "@opentelemetry/sdk-trace-base"
- openai
- ws
"@mistralai/mistralai@1.5.2(zod@3.24.2)":
dependencies:
@@ -5919,6 +5940,8 @@ snapshots:
dependencies:
"@sinonjs/commons": 3.0.1
"@standard-schema/spec@1.1.0": {}
"@supabase/auth-js@2.68.0":
dependencies:
"@supabase/node-fetch": 2.6.15
@@ -6038,8 +6061,6 @@ snapshots:
"@types/phoenix@1.6.6": {}
"@types/retry@0.12.0": {}
"@types/stack-utils@2.0.3": {}
"@types/uuid@10.0.0": {}
@@ -6307,6 +6328,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
char-regex@1.0.2: {}
charenc@0.0.2: {}
@@ -7389,17 +7412,17 @@ snapshots:
kolorist@1.8.0: {}
langsmith@0.3.15(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)):
langsmith@0.5.10(openai@4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2))(ws@8.18.1):
dependencies:
"@types/uuid": 10.0.0
chalk: 4.1.2
chalk: 5.6.2
console-table-printer: 2.12.1
p-queue: 6.6.2
p-retry: 4.6.2
semver: 7.7.1
uuid: 10.0.0
optionalDependencies:
openai: 4.93.0(encoding@0.1.13)(ws@8.18.1)(zod@3.24.2)
ws: 8.18.1
leven@3.1.0: {}
@@ -7641,11 +7664,6 @@ snapshots:
eventemitter3: 4.0.7
p-timeout: 3.2.0
p-retry@4.6.2:
dependencies:
"@types/retry": 0.12.0
retry: 0.13.1
p-timeout@3.2.0:
dependencies:
p-finally: 1.0.0
@@ -7900,8 +7918,6 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
retry@0.13.1: {}
reusify@1.1.0: {}
rimraf@5.0.10:
@@ -8313,6 +8329,8 @@ snapshots:
uuid@10.0.0: {}
uuid@11.1.0: {}
uuid@8.3.2: {}
uuid@9.0.1: {}
@@ -8415,3 +8433,5 @@ snapshots:
zod: 3.24.2
zod@3.24.2: {}
zod@3.25.76: {}
+19 -3
View File
@@ -20,15 +20,24 @@ export class ConfigManager {
finalModel = userConf.model;
}
// Normalize snake_case keys from Python SDK / OpenClaw configs
const baseURL =
userConf?.baseURL ??
(userConf as Record<string, unknown>)?.lmstudio_base_url as string | undefined ??
userConf?.url;
const embeddingDims =
userConf?.embeddingDims ??
(userConf as Record<string, unknown>)?.embedding_dims as number | undefined;
return {
apiKey:
userConf?.apiKey !== undefined
? userConf.apiKey
: defaultConf.apiKey,
model: finalModel,
baseURL: userConf?.baseURL,
baseURL,
url: userConf?.url,
embeddingDims: userConf?.embeddingDims,
embeddingDims,
modelProperties:
userConf?.modelProperties !== undefined
? userConf.modelProperties
@@ -91,8 +100,15 @@ export class ConfigManager {
finalModel = userConf.model;
}
// Normalize snake_case keys from Python SDK / OpenClaw configs
const llmBaseURL =
userConf?.baseURL ??
(userConf as Record<string, unknown>)?.lmstudio_base_url as string | undefined ??
defaultConf.baseURL;
return {
baseURL: userConf?.baseURL || defaultConf.baseURL,
baseURL: llmBaseURL,
url: userConf?.url,
apiKey:
userConf?.apiKey !== undefined
? userConf.apiKey
@@ -0,0 +1,53 @@
import OpenAI from "openai";
import { Embedder } from "./base";
import { EmbeddingConfig } from "../types";
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
const DEFAULT_MODEL =
"nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf";
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
export class LMStudioEmbedder implements Embedder {
private openai: OpenAI;
private model: string;
constructor(config: EmbeddingConfig) {
const baseURL = config.baseURL ?? config.url ?? DEFAULT_BASE_URL;
const apiKey = config.apiKey || DEFAULT_LMSTUDIO_API_KEY;
this.openai = new OpenAI({ apiKey, baseURL: String(baseURL) });
this.model = config.model || DEFAULT_MODEL;
}
async embed(text: string): Promise<number[]> {
const normalized =
typeof text === "string" ? text.replace(/\n/g, " ") : String(text);
try {
const response = await this.openai.embeddings.create({
model: this.model,
input: normalized,
encoding_format: "float",
});
return response.data[0].embedding;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio embedder failed: ${message}`);
}
}
async embedBatch(texts: string[]): Promise<number[][]> {
const normalized = texts.map((t) =>
typeof t === "string" ? t.replace(/\n/g, " ") : String(t),
);
try {
const response = await this.openai.embeddings.create({
model: this.model,
input: normalized,
encoding_format: "float",
});
return response.data.map((item) => item.embedding);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio embedder failed: ${message}`);
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export class OllamaEmbedder implements Embedder {
constructor(config: EmbeddingConfig) {
this.ollama = new Ollama({
host: config.url || "http://localhost:11434",
host: config.url || config.baseURL || "http://localhost:11434",
});
this.model = config.model || "nomic-embed-text:latest";
this.embeddingDims = config.embeddingDims || 768;
+2
View File
@@ -4,6 +4,7 @@ export * from "./types";
export * from "./embeddings/base";
export * from "./embeddings/openai";
export * from "./embeddings/ollama";
export * from "./embeddings/lmstudio";
export * from "./embeddings/google";
export * from "./embeddings/azure";
export * from "./embeddings/langchain";
@@ -14,6 +15,7 @@ export * from "./llms/openai_structured";
export * from "./llms/anthropic";
export * from "./llms/groq";
export * from "./llms/ollama";
export * from "./llms/lmstudio";
export * from "./llms/mistral";
export * from "./llms/langchain";
export * from "./vector_stores/base";
+41
View File
@@ -0,0 +1,41 @@
import { OpenAILLM } from "./openai";
import { LLMConfig, Message } from "../types";
import { LLMResponse } from "./base";
const DEFAULT_BASE_URL = "http://localhost:1234/v1";
const DEFAULT_MODEL =
"lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/Meta-Llama-3.1-70B-Instruct-IQ2_M.gguf";
const DEFAULT_LMSTUDIO_API_KEY = "lm-studio";
export class LMStudioLLM extends OpenAILLM {
constructor(config: LLMConfig) {
super({
...config,
apiKey: config.apiKey || DEFAULT_LMSTUDIO_API_KEY,
baseURL: config.baseURL ?? DEFAULT_BASE_URL,
model: config.model || DEFAULT_MODEL,
});
}
async generateResponse(
messages: Message[],
responseFormat?: { type: string },
tools?: any[],
): Promise<string | LLMResponse> {
try {
return await super.generateResponse(messages, responseFormat, tools);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio LLM failed: ${message}`);
}
}
async generateChat(messages: Message[]): Promise<LLMResponse> {
try {
return await super.generateChat(messages);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`LM Studio LLM failed: ${message}`);
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export class OllamaLLM implements LLM {
constructor(config: LLMConfig) {
this.ollama = new Ollama({
host: config.config?.url || "http://localhost:11434",
host: config.url || config.baseURL || "http://localhost:11434",
});
this.model = config.model || "llama3.1:8b";
this.ensureModelExists().catch((err) => {
+2
View File
@@ -43,6 +43,7 @@ export interface HistoryStoreConfig {
export interface LLMConfig {
provider?: string;
baseURL?: string;
url?: string;
config?: Record<string, any>;
apiKey?: string;
model?: string | any;
@@ -143,6 +144,7 @@ export const MemoryConfigSchema = z.object({
model: z.union([z.string(), z.any()]).optional(),
modelProperties: z.record(z.string(), z.any()).optional(),
baseURL: z.string().optional(),
url: z.string().optional(),
}),
}),
historyDbPath: z.string().optional(),
+6
View File
@@ -1,5 +1,6 @@
import { OpenAIEmbedder } from "../embeddings/openai";
import { OllamaEmbedder } from "../embeddings/ollama";
import { LMStudioEmbedder } from "../embeddings/lmstudio";
import { OpenAILLM } from "../llms/openai";
import { OpenAIStructuredLLM } from "../llms/openai_structured";
import { AnthropicLLM } from "../llms/anthropic";
@@ -19,6 +20,7 @@ import { Qdrant } from "../vector_stores/qdrant";
import { VectorizeDB } from "../vector_stores/vectorize";
import { RedisDB } from "../vector_stores/redis";
import { OllamaLLM } from "../llms/ollama";
import { LMStudioLLM } from "../llms/lmstudio";
import { SupabaseDB } from "../vector_stores/supabase";
import { SQLiteManager } from "../storage/SQLiteManager";
import { MemoryHistoryManager } from "../storage/MemoryHistoryManager";
@@ -40,6 +42,8 @@ export class EmbedderFactory {
return new OpenAIEmbedder(config);
case "ollama":
return new OllamaEmbedder(config);
case "lmstudio":
return new LMStudioEmbedder(config);
case "google":
case "gemini":
return new GoogleEmbedder(config);
@@ -66,6 +70,8 @@ export class LLMFactory {
return new GroqLLM(config);
case "ollama":
return new OllamaLLM(config);
case "lmstudio":
return new LMStudioLLM(config);
case "google":
case "gemini":
return new GoogleLLM(config);
@@ -85,4 +85,446 @@ describe("ConfigManager", () => {
expect(config.vectorStore.config.dimension).toBe(768);
});
});
describe("mergeConfig - LLM url passthrough for Ollama", () => {
const baseEmbedder = {
provider: "openai",
config: { apiKey: "test-key" },
};
const baseVectorStore = {
provider: "memory",
config: { collectionName: "test" },
};
it("should preserve url in LLM config when provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
it("should prefer baseURL over url when both are provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: {
model: "llama3.2:3b",
baseURL: "http://custom:11434",
url: "http://fallback:11434",
},
},
});
expect(config.llm.config.baseURL).toBe("http://custom:11434");
expect(config.llm.config.url).toBe("http://fallback:11434");
});
it("should use default baseURL when no url or baseURL provided", () => {
const config = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b" },
},
});
expect(config.llm.config.url).toBeUndefined();
expect(config.llm.config.baseURL).toBe("https://api.openai.com/v1");
});
it("should preserve url in embedder config (existing behavior)", () => {
const config = ConfigManager.mergeConfig({
embedder: {
provider: "ollama",
config: {
model: "nomic-embed-text",
url: "http://10.0.0.100:11434",
},
},
vectorStore: baseVectorStore,
llm: {
provider: "ollama",
config: { model: "llama3.2:3b", url: "http://10.0.0.100:11434" },
},
});
expect(config.embedder.config.url).toBe("http://10.0.0.100:11434");
expect(config.llm.config.url).toBe("http://10.0.0.100:11434");
});
});
// ─────────────────────────────────────────────────────────────────────
// LM Studio snake_case normalization
// ─────────────────────────────────────────────────────────────────────
describe("mergeConfig - LM Studio embedder config", () => {
const baseLlm = { provider: "openai", config: { apiKey: "k" } };
it("normalizes lmstudio_base_url to baseURL for embedder", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
lmstudio_base_url: "http://192.168.1.1:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.provider).toBe("lmstudio");
expect(cfg.embedder.config.baseURL).toBe("http://192.168.1.1:1234/v1");
expect(cfg.embedder.config.model).toBe("nomic-embed-text-v1.5");
});
it("normalizes embedding_dims to embeddingDims for embedder", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: { model: "nomic-embed-text-v1.5", embedding_dims: 768 } as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.embeddingDims).toBe(768);
});
it("prefers camelCase baseURL over snake_case lmstudio_base_url", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "test",
baseURL: "http://camel:1234/v1",
lmstudio_base_url: "http://snake:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.baseURL).toBe("http://camel:1234/v1");
});
it("prefers camelCase embeddingDims over snake_case embedding_dims", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "test",
embeddingDims: 1536,
embedding_dims: 768,
} as any,
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.embeddingDims).toBe(1536);
});
it("passes through camelCase config without issues", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "nomic-embed-text-v1.5",
baseURL: "http://localhost:1234/v1",
embeddingDims: 768,
},
},
vectorStore: { provider: "memory", config: {} },
llm: baseLlm,
});
expect(cfg.embedder.config.baseURL).toBe("http://localhost:1234/v1");
expect(cfg.embedder.config.embeddingDims).toBe(768);
});
});
describe("mergeConfig - LM Studio LLM config", () => {
const baseEmbedder = { provider: "openai", config: { apiKey: "k" } };
it("normalizes lmstudio_base_url to baseURL for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: {
provider: "lmstudio",
config: {
model: "meta-llama-3.1",
lmstudio_base_url: "http://192.168.1.1:1234/v1",
} as any,
},
});
expect(cfg.llm.provider).toBe("lmstudio");
expect(cfg.llm.config.baseURL).toBe("http://192.168.1.1:1234/v1");
expect(cfg.llm.config.model).toBe("meta-llama-3.1");
});
it("prefers camelCase baseURL over lmstudio_base_url for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: {
provider: "lmstudio",
config: {
baseURL: "http://camel:1234/v1",
lmstudio_base_url: "http://snake:1234/v1",
} as any,
},
});
expect(cfg.llm.config.baseURL).toBe("http://camel:1234/v1");
});
it("falls back to default baseURL when neither is provided for LLM", () => {
const cfg = ConfigManager.mergeConfig({
embedder: baseEmbedder,
vectorStore: { provider: "memory", config: {} },
llm: { provider: "lmstudio", config: { model: "test-model" } },
});
expect(cfg.llm.config.baseURL).toBe("https://api.openai.com/v1");
});
});
describe("mergeConfig - full OpenClaw-style LM Studio config", () => {
it("handles the exact config from issue #4235", () => {
const cfg = ConfigManager.mergeConfig({
embedder: {
provider: "lmstudio",
config: {
model: "text-embedding-gte-qwen2-1.5b-instruct",
embedding_dims: 1536,
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
vectorStore: {
provider: "qdrant",
config: { host: "192.168.200.12", port: 6333, checkCompatibility: false },
},
llm: {
provider: "lmstudio",
config: {
model: "openai/gpt-oss-20b",
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
});
expect(cfg.embedder.provider).toBe("lmstudio");
expect(cfg.embedder.config.baseURL).toBe("http://192.168.200.83:1234/v1");
expect(cfg.embedder.config.model).toBe("text-embedding-gte-qwen2-1.5b-instruct");
expect(cfg.embedder.config.embeddingDims).toBe(1536);
expect(cfg.llm.provider).toBe("lmstudio");
expect(cfg.llm.config.baseURL).toBe("http://192.168.200.83:1234/v1");
expect(cfg.llm.config.model).toBe("openai/gpt-oss-20b");
expect(cfg.vectorStore.provider).toBe("qdrant");
expect(cfg.vectorStore.config.host).toBe("192.168.200.12");
expect(cfg.vectorStore.config.port).toBe(6333);
});
});
});
// ─────────────────────────────────────────────────────────────────────────
// Memory class – LM Studio end-to-end flow (mocked factories)
// ─────────────────────────────────────────────────────────────────────────
describe("Memory – LM Studio end-to-end flow", () => {
let MemoryClass: any;
let mockEmbedderFactory: any;
let mockVectorStoreFactory: any;
let mockLlmFactory: any;
let mockHistoryFactory: any;
let mockEmbedder: any;
let mockVStore: any;
let mockLlm: any;
beforeEach(() => {
jest.resetModules();
mockEmbedder = {
embed: jest.fn().mockResolvedValue(new Array(768).fill(0.1)),
embedBatch: jest.fn().mockResolvedValue([new Array(768).fill(0.1)]),
};
mockVStore = {
insert: jest.fn().mockResolvedValue(undefined),
search: jest.fn().mockResolvedValue([]),
get: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
deleteCol: jest.fn().mockResolvedValue(undefined),
list: jest.fn().mockResolvedValue([[], 0]),
getUserId: jest.fn().mockResolvedValue("test-user-id"),
setUserId: jest.fn().mockResolvedValue(undefined),
initialize: jest.fn().mockResolvedValue(undefined),
};
mockLlm = {
generateResponse: jest.fn().mockResolvedValue('{"facts":[]}'),
};
mockEmbedderFactory = { create: jest.fn().mockReturnValue(mockEmbedder) };
mockVectorStoreFactory = { create: jest.fn().mockReturnValue(mockVStore) };
mockLlmFactory = { create: jest.fn().mockReturnValue(mockLlm) };
mockHistoryFactory = {
create: jest.fn().mockReturnValue({
addHistory: jest.fn().mockResolvedValue(undefined),
getHistory: jest.fn().mockResolvedValue([]),
reset: jest.fn().mockResolvedValue(undefined),
}),
};
jest.doMock("../src/utils/factory", () => ({
EmbedderFactory: mockEmbedderFactory,
VectorStoreFactory: mockVectorStoreFactory,
LLMFactory: mockLlmFactory,
HistoryManagerFactory: mockHistoryFactory,
}));
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
}));
MemoryClass = require("../src/memory").Memory;
});
afterEach(() => {
jest.restoreAllMocks();
jest.resetModules();
});
it("creates Memory with lmstudio embedder and llm providers", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
},
vectorStore: { provider: "memory", config: { collectionName: "test" } },
llm: {
provider: "lmstudio",
config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" },
},
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({ model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" }),
);
expect(mockLlmFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({ model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" }),
);
});
it("auto-detects embedding dimension via probe with lmstudio", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1" },
},
vectorStore: { provider: "qdrant", config: { collectionName: "test" } },
llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } },
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedder.embed).toHaveBeenCalledWith("dimension probe");
const vsCall = mockVectorStoreFactory.create.mock.calls[0];
expect(vsCall[1].dimension).toBe(768);
});
it("handles snake_case OpenClaw config through full Memory stack", async () => {
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: {
model: "text-embedding-gte-qwen2-1.5b-instruct",
embedding_dims: 1536,
lmstudio_base_url: "http://192.168.200.83:1234/v1",
} as any,
},
vectorStore: { provider: "memory", config: { collectionName: "test" } },
llm: {
provider: "lmstudio",
config: { model: "openai/gpt-oss-20b", lmstudio_base_url: "http://192.168.200.83:1234/v1" } as any,
},
disableHistory: true,
});
await mem.getAll({ userId: "u1" });
expect(mockEmbedderFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({ model: "text-embedding-gte-qwen2-1.5b-instruct", baseURL: "http://192.168.200.83:1234/v1" }),
);
expect(mockLlmFactory.create).toHaveBeenCalledWith(
"lmstudio",
expect.objectContaining({ model: "openai/gpt-oss-20b", baseURL: "http://192.168.200.83:1234/v1" }),
);
});
it("search flow works with lmstudio embedder", async () => {
mockVStore.search.mockResolvedValueOnce([{
id: "mem-1",
payload: { data: "User likes hiking", user_id: "u1", hash: "abc123", created_at: "2026-01-01" },
score: 0.95,
}]);
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 },
},
vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } },
llm: { provider: "lmstudio", config: { baseURL: "http://localhost:1234/v1" } },
disableHistory: true,
});
const result = await mem.search("What does the user like?", { userId: "u1" });
expect(mockEmbedder.embed).toHaveBeenCalledWith("What does the user like?");
expect(mockVStore.search).toHaveBeenCalled();
expect(result.results).toHaveLength(1);
expect(result.results[0].memory).toBe("User likes hiking");
});
it("add flow works with lmstudio LLM for fact extraction", async () => {
mockLlm.generateResponse.mockResolvedValueOnce('{"facts":["User loves sushi"]}');
mockVStore.search.mockResolvedValue([]);
mockVStore.list.mockResolvedValue([[], 0]);
const mem = new MemoryClass({
embedder: {
provider: "lmstudio",
config: { model: "nomic-embed-text-v1.5", baseURL: "http://localhost:1234/v1", embeddingDims: 768 },
},
vectorStore: { provider: "memory", config: { collectionName: "test", dimension: 768 } },
llm: {
provider: "lmstudio",
config: { model: "meta-llama-3.1-70b", baseURL: "http://localhost:1234/v1" },
},
disableHistory: true,
});
await mem.add("I love sushi", { userId: "u1" });
expect(mockLlm.generateResponse).toHaveBeenCalled();
expect(mockEmbedder.embed).toHaveBeenCalled();
});
});
+24 -1
View File
@@ -1,7 +1,30 @@
/// <reference types="jest" />
import { VectorStoreFactory } from "../src/utils/factory";
import {
EmbedderFactory,
VectorStoreFactory,
} from "../src/utils/factory";
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
import { AzureAISearch } from "../src/vector_stores/azure_ai_search";
describe("EmbedderFactory", () => {
describe("create", () => {
it("should create LM Studio embedder with baseURL", () => {
const embedder = EmbedderFactory.create("lmstudio", {
model: "text-embedding-gte-qwen2-1.5b-instruct",
baseURL: "http://localhost:1234/v1",
});
expect(embedder).toBeInstanceOf(LMStudioEmbedder);
});
it("should throw error for unsupported embedder provider", () => {
expect(() => {
EmbedderFactory.create("unsupported-embedder", {});
}).toThrow("Unsupported embedder provider: unsupported-embedder");
});
});
});
describe("VectorStoreFactory", () => {
describe("create", () => {
it("should create Azure AI Search vector store", () => {
@@ -0,0 +1,83 @@
/// <reference types="jest" />
/**
* LM Studio Embedder — unit tests (mocked OpenAI).
*/
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5];
const mockCreate = jest.fn().mockResolvedValue({
data: [{ embedding: mockEmbedding }],
});
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
embeddings: { create: mockCreate },
}));
});
describe("LMStudioEmbedder (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("embed() calls OpenAI with encoding_format float and returns vector", async () => {
const embedder = new LMStudioEmbedder({
model: "nomic-embed-text-v1.5-GGUF",
baseURL: "http://localhost:1234/v1",
});
const result = await embedder.embed("Sample text to embed.");
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate.mock.calls[0][0]).toEqual({
model: "nomic-embed-text-v1.5-GGUF",
input: "Sample text to embed.",
encoding_format: "float",
});
expect(result).toEqual(mockEmbedding);
});
it("embed() normalizes newlines", async () => {
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
await embedder.embed("Line one\nLine two");
expect(mockCreate.mock.calls[0][0].input).toBe("Line one Line two");
});
it("embed() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
await expect(embedder.embed("text")).rejects.toThrow(
"LM Studio embedder failed: Connection refused",
);
});
it("embedBatch() returns vectors for multiple inputs", async () => {
const mockBatch = [[0.1, 0.2], [0.3, 0.4]];
mockCreate.mockResolvedValueOnce({
data: [
{ embedding: mockBatch[0] },
{ embedding: mockBatch[1] },
],
});
const embedder = new LMStudioEmbedder({
model: "test-model",
baseURL: "http://localhost:1234/v1",
});
const result = await embedder.embedBatch(["text1", "text2"]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreate.mock.calls[0][0].input).toEqual(["text1", "text2"]);
expect(result).toEqual(mockBatch);
});
});
@@ -0,0 +1,170 @@
/// <reference types="jest" />
/**
* LM Studio integration tests against a real local server.
* Skipped by default. Enable with: LMSTUDIO_INTEGRATION=1
*
* Prerequisites:
* 1. LM Studio installed with `lms` CLI
* 2. Server running: lms server start
* 3. Embedding model loaded: lms load text-embedding-nomic-embed-text-v1.5
* 4. (Optional) Chat model loaded for LLM tests
*/
import { LMStudioEmbedder } from "../src/embeddings/lmstudio";
import { LMStudioLLM } from "../src/llms/lmstudio";
const LMSTUDIO_BASE_URL =
process.env.LMSTUDIO_BASE_URL || "http://localhost:1234/v1";
const RUN_INTEGRATION = process.env.LMSTUDIO_INTEGRATION === "1";
const describeIf = RUN_INTEGRATION ? describe : describe.skip;
jest.setTimeout(120_000);
async function listModels(): Promise<{ embedding: string | null; chat: string | null }> {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
const body = await res.json();
const models: any[] = body.data || [];
const embedding = models.find((m) => m.id.includes("embed") || m.id.includes("nomic"));
const chat = models.find((m) => !m.id.includes("embed") && !m.id.includes("nomic"));
return { embedding: embedding?.id ?? null, chat: chat?.id ?? null };
}
function cosineSim(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : dot / denom;
}
describeIf("LM Studio Integration", () => {
it("server is reachable and lists models", async () => {
const res = await fetch(`${LMSTUDIO_BASE_URL}/models`);
expect(res.ok).toBe(true);
const body = await res.json();
expect(body.data).toBeDefined();
console.log("Loaded models:", body.data.map((m: any) => m.id));
});
// ─── Embedder ────────────────────────────────────────────────────────
describe("LMStudioEmbedder (real server)", () => {
let embedder: LMStudioEmbedder;
let modelId: string;
beforeAll(async () => {
const models = await listModels();
if (!models.embedding) throw new Error("No embedding model loaded");
modelId = models.embedding;
embedder = new LMStudioEmbedder({ baseURL: LMSTUDIO_BASE_URL, model: modelId });
});
it("embed() returns a numeric vector", async () => {
const vector = await embedder.embed("Hello world");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
vector.forEach((v) => expect(typeof v).toBe("number"));
console.log(` Model: ${modelId}, dimension: ${vector.length}`);
});
it("embed() produces identical output for newline-normalized text", async () => {
const v1 = await embedder.embed("hello world");
const v2 = await embedder.embed("hello\nworld");
expect(v1.length).toBe(v2.length);
const totalDiff = v1.reduce((s, val, i) => s + Math.abs(val - v2[i]), 0);
expect(totalDiff).toBeLessThan(0.001);
});
it("embedBatch() returns correct number of vectors", async () => {
const vectors = await embedder.embedBatch(["first", "second", "third"]);
expect(vectors).toHaveLength(3);
vectors.forEach((v) => {
expect(v.length).toBe(vectors[0].length);
v.forEach((val) => expect(typeof val).toBe("number"));
});
});
it("semantically similar texts have higher cosine similarity", async () => {
const [v1, v2, v3] = await Promise.all([
embedder.embed("I love hiking in the mountains"),
embedder.embed("I enjoy trekking through mountain trails"),
embedder.embed("The stock market crashed yesterday"),
]);
const simSimilar = cosineSim(v1, v2);
const simDifferent = cosineSim(v1, v3);
console.log(` Similar: ${simSimilar.toFixed(4)}, Different: ${simDifferent.toFixed(4)}`);
expect(Number.isFinite(simSimilar)).toBe(true);
expect(Number.isFinite(simDifferent)).toBe(true);
expect(simSimilar).toBeGreaterThan(simDifferent);
});
it("embed() handles empty string", async () => {
const vector = await embedder.embed("");
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
it("embed() handles long text", async () => {
const longText = "This is a test sentence. ".repeat(200);
const vector = await embedder.embed(longText);
expect(Array.isArray(vector)).toBe(true);
expect(vector.length).toBeGreaterThan(0);
});
});
// ─── LLM ─────────────────────────────────────────────────────────────
describe("LMStudioLLM (real server)", () => {
let llm: LMStudioLLM;
let chatModelId: string | null;
beforeAll(async () => {
const models = await listModels();
chatModelId = models.chat;
if (!chatModelId) {
console.warn("No chat model loaded — LLM tests will be skipped");
return;
}
llm = new LMStudioLLM({ baseURL: LMSTUDIO_BASE_URL, model: chatModelId });
});
it("generateResponse() returns a response", async () => {
if (!chatModelId) return;
const result = await llm.generateResponse([
{ role: "user", content: "Say hello in exactly 3 words." },
]);
if (typeof result === "string") {
expect(result.length).toBeGreaterThan(0);
console.log(` Response (string): ${result.slice(0, 100)}`);
} else {
expect(result).toHaveProperty("content");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Response (object): ${result.content.slice(0, 100)}`);
}
});
it("generateChat() returns LLMResponse with content and role", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "What is 2+2?" },
]);
expect(result).toHaveProperty("content");
expect(result).toHaveProperty("role");
expect(result.role).toBe("assistant");
expect(result.content.length).toBeGreaterThan(0);
console.log(` Chat: ${result.content.slice(0, 100)}`);
});
it("generateChat() handles multi-turn conversation", async () => {
if (!chatModelId) return;
const result = await llm.generateChat([
{ role: "user", content: "My name is Alice." },
{ role: "assistant", content: "Hello Alice!" },
{ role: "user", content: "What is my name?" },
]);
expect(result.content.length).toBeGreaterThan(0);
console.log(` Multi-turn: ${result.content.slice(0, 100)}`);
});
});
});
@@ -0,0 +1,96 @@
/// <reference types="jest" />
/**
* LM Studio LLM — unit tests (mocked OpenAI).
*/
import { LMStudioLLM } from "../src/llms/lmstudio";
const mockCreate = jest.fn();
jest.mock("openai", () => {
return jest.fn().mockImplementation(() => ({
chat: { completions: { create: mockCreate } },
}));
});
describe("LMStudioLLM (unit)", () => {
beforeEach(() => mockCreate.mockClear());
it("generateResponse() returns a text response", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{
message: { content: "Hello, world!", role: "assistant", tool_calls: null },
}],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateResponse([
{ role: "user", content: "Hi" },
]);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(result).toBe("Hello, world!");
});
it("generateResponse() handles tool calls", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{
message: {
content: "",
role: "assistant",
tool_calls: [{
function: { name: "get_weather", arguments: '{"city": "London"}' },
}],
},
}],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateResponse(
[{ role: "user", content: "What is the weather?" }],
undefined,
[{ type: "function", function: { name: "get_weather" } }],
);
expect(result).toEqual({
content: "",
role: "assistant",
toolCalls: [{ name: "get_weather", arguments: '{"city": "London"}' }],
});
});
it("generateResponse() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Connection refused"));
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
await expect(
llm.generateResponse([{ role: "user", content: "Hi" }]),
).rejects.toThrow("LM Studio LLM failed: Connection refused");
});
it("generateChat() returns LLMResponse shape", async () => {
mockCreate.mockResolvedValueOnce({
choices: [{
message: { content: "I can help with that.", role: "assistant" },
}],
});
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
const result = await llm.generateChat([
{ role: "user", content: "Help me" },
]);
expect(result).toEqual({ content: "I can help with that.", role: "assistant" });
});
it("generateChat() wraps API errors with a clear message", async () => {
mockCreate.mockRejectedValueOnce(new Error("Timeout"));
const llm = new LMStudioLLM({ baseURL: "http://localhost:1234/v1" });
await expect(
llm.generateChat([{ role: "user", content: "Hi" }]),
).rejects.toThrow("LM Studio LLM failed: Timeout");
});
});
+2 -2
View File
@@ -97,7 +97,7 @@ class NeptuneBase(ABC):
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
for item in tool_call.get("arguments", {}).get("entities", []):
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
@@ -144,7 +144,7 @@ class NeptuneBase(ABC):
entities = []
if extracted_entities["tool_calls"]:
entities = extracted_entities["tool_calls"][0]["arguments"]["entities"]
entities = extracted_entities["tool_calls"][0].get("arguments", {}).get("entities", [])
entities = self._remove_spaces_from_entities(entities)
logger.debug(f"Extracted entities: {entities}")
+1 -1
View File
@@ -215,7 +215,7 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
for item in tool_call.get("arguments", {}).get("entities", []):
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
+1 -1
View File
@@ -241,7 +241,7 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
for item in tool_call.get("arguments", {}).get("entities", []):
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
logger.exception(
+42 -35
View File
@@ -24,8 +24,9 @@ from mem0.exceptions import ValidationError as Mem0ValidationError
from mem0.memory.base import MemoryBase
from mem0.memory.setup import mem0_dir, setup_config
from mem0.memory.storage import SQLiteManager
from mem0.memory.telemetry import capture_event
from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event
from mem0.memory.utils import (
ensure_json_instruction,
extract_json,
get_fact_retrieval_messages,
parse_messages,
@@ -37,8 +38,8 @@ from mem0.utils.factory import (
EmbedderFactory,
GraphStoreFactory,
LlmFactory,
VectorStoreFactory,
RerankerFactory,
VectorStoreFactory,
)
# Suppress SWIG deprecation warnings globally
@@ -204,32 +205,33 @@ class Memory(MemoryBase):
self.enable_graph = True
else:
self.graph = None
# Create telemetry config manually to avoid deepcopy issues with thread locks
telemetry_config_dict = {}
if hasattr(self.config.vector_store.config, 'model_dump'):
# For pydantic models
telemetry_config_dict = self.config.vector_store.config.model_dump()
else:
# For other objects, manually copy common attributes
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
if hasattr(self.config.vector_store.config, attr):
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
if MEM0_TELEMETRY:
# Create telemetry config manually to avoid deepcopy issues with thread locks
telemetry_config_dict = {}
if hasattr(self.config.vector_store.config, 'model_dump'):
# For pydantic models
telemetry_config_dict = self.config.vector_store.config.model_dump()
else:
# For other objects, manually copy common attributes
for attr in ['host', 'port', 'path', 'api_key', 'index_name', 'dimension', 'metric']:
if hasattr(self.config.vector_store.config, attr):
telemetry_config_dict[attr] = getattr(self.config.vector_store.config, attr)
# Override collection name for telemetry
telemetry_config_dict['collection_name'] = "mem0migrations"
# Override collection name for telemetry
telemetry_config_dict['collection_name'] = "mem0migrations"
# Set path for file-based vector stores
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
# Set path for file-based vector stores
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config_dict['path'] = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config_dict['path'], exist_ok=True)
# Create the config object using the same class as the original
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
self._telemetry_vector_store = VectorStoreFactory.create(
self.config.vector_store.provider, telemetry_config
)
# Create the config object using the same class as the original
telemetry_config = self.config.vector_store.config.__class__(**telemetry_config_dict)
self._telemetry_vector_store = VectorStoreFactory.create(
self.config.vector_store.provider, telemetry_config
)
capture_event("mem0.init", self, {"sync_type": "sync"})
@classmethod
@@ -431,6 +433,9 @@ class Memory(MemoryBase):
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
# Ensure 'json' appears in prompts for json_object response format compatibility
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
response = self.llm.generate_response(
messages=[
{"role": "system", "content": system_prompt},
@@ -1046,11 +1051,10 @@ class Memory(MemoryBase):
keys, encoded_ids = process_telemetry_filters(filters)
capture_event("mem0.delete_all", self, {"keys": keys, "encoded_ids": encoded_ids, "sync_type": "sync"})
# delete all vector memories and reset the collections
# delete matching vector memories individually (do NOT reset the collection)
memories = self.vector_store.list(filters=filters)[0]
for memory in memories:
self._delete_memory(memory.id)
self.vector_store.reset()
logger.info(f"Deleted {len(memories)} memories")
@@ -1272,14 +1276,14 @@ class AsyncMemory(MemoryBase):
else:
self.graph = None
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
telemetry_config.collection_name = "mem0migrations"
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config.path = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config.path, exist_ok=True)
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
if MEM0_TELEMETRY:
telemetry_config = _safe_deepcopy_config(self.config.vector_store.config)
telemetry_config.collection_name = "mem0migrations"
if self.config.vector_store.provider in ["faiss", "qdrant"]:
provider_path = f"migrations_{self.config.vector_store.provider}"
telemetry_config.path = os.path.join(mem0_dir, provider_path)
os.makedirs(telemetry_config.path, exist_ok=True)
self._telemetry_vector_store = VectorStoreFactory.create(self.config.vector_store.provider, telemetry_config)
capture_event("mem0.init", self, {"sync_type": "async"})
@classmethod
@@ -1460,6 +1464,9 @@ class AsyncMemory(MemoryBase):
is_agent_memory = self._should_use_agent_memory_extraction(messages, metadata)
system_prompt, user_prompt = get_fact_retrieval_messages(parsed_messages, is_agent_memory)
# Ensure 'json' appears in prompts for json_object response format compatibility
system_prompt, user_prompt = ensure_json_instruction(system_prompt, user_prompt)
response = await asyncio.to_thread(
self.llm.generate_response,
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
+1 -1
View File
@@ -218,7 +218,7 @@ class MemoryGraph:
for tool_call in search_results["tool_calls"]:
if tool_call["name"] != "extract_entities":
continue
for item in tool_call["arguments"]["entities"]:
for item in tool_call.get("arguments", {}).get("entities", []):
if "entity" in item and "entity_type" in item:
entity_type_map[item["entity"]] = item["entity_type"]
except Exception as e:
+25
View File
@@ -29,6 +29,31 @@ def get_fact_retrieval_messages_legacy(message):
return FACT_RETRIEVAL_PROMPT, f"Input:\n{message}"
def ensure_json_instruction(system_prompt, user_prompt):
"""Ensure the word 'json' appears in the prompts when using json_object response format.
OpenAI's API requires the word 'json' to appear in the messages when
response_format is set to {"type": "json_object"}. When users provide a
custom_fact_extraction_prompt that doesn't include 'json', this causes a
400 error. This function appends a JSON format instruction to the system
prompt if 'json' is not already present in either prompt.
Args:
system_prompt: The system prompt string
user_prompt: The user prompt string
Returns:
tuple: (system_prompt, user_prompt) with JSON instruction added if needed
"""
combined = (system_prompt + user_prompt).lower()
if "json" not in combined:
system_prompt += (
"\n\nYou must return your response in valid JSON format "
"with a 'facts' key containing an array of strings."
)
return system_prompt, user_prompt
def parse_messages(messages):
response = ""
for msg in messages:
+1 -1
View File
@@ -159,7 +159,7 @@ class RedisDB(VectorStoreBase):
return [
MemoryResult(
id=result["memory_id"],
score=result["vector_distance"],
score=float(result["vector_distance"]),
payload={
"hash": result["hash"],
"data": result["memory"],
+2 -2
View File
@@ -180,11 +180,11 @@ Works with zero extra config. The `oss` block lets you swap out any component:
| Key | Type | Default | |
|-----|------|---------|---|
| `customPrompt` | `string` | *(built-in)* | Extraction prompt for memory processing |
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, etc.) |
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider (`"openai"`, `"ollama"`, `"lmstudio"`, etc.) |
| `oss.embedder.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL` |
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store (`"memory"`, `"qdrant"`, `"chroma"`, etc.) |
| `oss.vectorStore.config` | `object` | — | Provider config: `host`, `port`, `collectionName`, `dimension` |
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, etc.) |
| `oss.llm.provider` | `string` | `"openai"` | LLM provider (`"openai"`, `"anthropic"`, `"ollama"`, `"lmstudio"`, etc.) |
| `oss.llm.config` | `object` | — | Provider config: `apiKey`, `model`, `baseURL`, `temperature` |
| `oss.historyDbPath` | `string` | — | SQLite path for memory edit history |
+33 -5
View File
@@ -41,6 +41,7 @@ type Mem0Config = {
vectorStore?: { provider: string; config: Record<string, unknown> };
llm?: { provider: string; config: Record<string, unknown> };
historyDbPath?: string;
disableHistory?: boolean;
};
// Shared
userId: string;
@@ -132,7 +133,10 @@ class PlatformProvider implements Mem0Provider {
private async ensureClient(): Promise<void> {
if (this.client) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init();
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
return this.initPromise;
}
@@ -225,7 +229,10 @@ class OSSProvider implements Mem0Provider {
private async ensureMemory(): Promise<void> {
if (this.memory) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this._init();
this.initPromise = this._init().catch((err) => {
this.initPromise = null;
throw err;
});
return this.initPromise;
}
@@ -246,9 +253,30 @@ class OSSProvider implements Mem0Provider {
config.historyDbPath = dbPath;
}
if (this.ossConfig?.disableHistory) {
config.disableHistory = true;
}
if (this.customPrompt) config.customPrompt = this.customPrompt;
this.memory = new Memory(config);
try {
this.memory = new Memory(config);
} catch (err) {
// If initialization fails (e.g. native SQLite binding resolution under
// jiti), retry with history disabled — the history DB is the most common
// source of native-binding failures and is not required for core
// memory operations.
if (!config.disableHistory) {
console.warn(
"[mem0] Memory initialization failed, retrying with history disabled:",
err instanceof Error ? err.message : err,
);
config.disableHistory = true;
this.memory = new Memory(config);
} else {
throw err;
}
}
}
async add(
@@ -521,7 +549,7 @@ function assertAllowedKeys(
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
const mem0ConfigSchema = {
export const mem0ConfigSchema = {
parse(value: unknown): Mem0Config {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("openclaw-mem0 config required");
@@ -587,7 +615,7 @@ const mem0ConfigSchema = {
// Provider Factory
// ============================================================================
function createProvider(
export function createProvider(
cfg: Mem0Config,
api: OpenClawPluginApi,
): Mem0Provider {
+1
View File
@@ -38,6 +38,7 @@
},
"devDependencies": {
"@types/node": "^22.15.0",
"@vitest/coverage-v8": "^4.0.18",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
+117
View File
@@ -18,6 +18,9 @@ importers:
'@types/node':
specifier: ^22.15.0
version: 22.19.15
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))
tsup:
specifier: ^8.5.0
version: 8.5.1(postcss@8.5.8)(typescript@5.9.3)
@@ -96,10 +99,27 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.0':
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
@@ -717,6 +737,15 @@ packages:
resolution: {integrity: sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ==}
engines: {node: '>=20.0.0'}
'@vitest/coverage-v8@4.1.0':
resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==}
peerDependencies:
'@vitest/browser': 4.1.0
vitest: 4.1.0
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.0':
resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
@@ -790,6 +819,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1176,6 +1208,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1228,6 +1263,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -1258,6 +1305,9 @@ packages:
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1402,6 +1452,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2235,8 +2292,21 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@cfworker/json-schema@4.1.1': {}
'@cloudflare/workers-types@4.20260313.1': {}
@@ -2733,6 +2803,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4)))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.27.4))
'@vitest/expect@4.1.0':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2802,6 +2886,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
asynckit@0.4.0: {}
axios@1.13.6:
@@ -3207,6 +3297,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -3251,6 +3343,19 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -3300,6 +3405,8 @@ snapshots:
dependencies:
base64-js: 1.5.1
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
json-bigint@1.0.0:
@@ -3418,6 +3525,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.0
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
math-intrinsics@1.1.0: {}
md5@2.3.0:
+2 -1
View File
@@ -1,4 +1,5 @@
approveBuilds: esbuild
packages:
- '.'
onlyBuiltDependencies:
- better-sqlite3
+288
View File
@@ -0,0 +1,288 @@
/**
* Tests for SQLite resilience fixes:
* 1. disableHistory config passthrough
* 2. initPromise poisoning fix (retry after failure)
* 3. Graceful SQLite fallback in OSSProvider
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mem0ConfigSchema, createProvider } from "./index.ts";
// ---------------------------------------------------------------------------
// 1. Config: disableHistory passthrough
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema — disableHistory", () => {
const baseConfig = {
mode: "open-source",
oss: {
embedder: { provider: "openai", config: { apiKey: "sk-test" } },
},
};
it("preserves oss.disableHistory: true through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
});
expect(cfg.oss?.disableHistory).toBe(true);
});
it("preserves oss.disableHistory: false through config parsing", () => {
const cfg = mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: false },
});
expect(cfg.oss?.disableHistory).toBe(false);
});
it("omits disableHistory when not provided", () => {
const cfg = mem0ConfigSchema.parse(baseConfig);
expect(cfg.oss?.disableHistory).toBeUndefined();
});
it("does not reject unknown keys inside oss object", () => {
// oss sub-object is passed through resolveEnvVarsDeep, not key-checked
expect(() =>
mem0ConfigSchema.parse({
...baseConfig,
oss: { ...baseConfig.oss, disableHistory: true },
}),
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// 2. OSSProvider: disableHistory flows to Memory constructor
// ---------------------------------------------------------------------------
describe("OSSProvider — disableHistory passthrough to Memory", () => {
let capturedConfig: Record<string, unknown> | undefined;
let memoryCallCount: number;
beforeEach(() => {
capturedConfig = undefined;
memoryCallCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
memoryCallCount++;
capturedConfig = { ...config };
}
async add() { return { results: [] }; }
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async delete() { }
},
}));
});
it("passes disableHistory: true to Memory when configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Trigger lazy init by calling search
try {
await provider.search("test", { user_id: "u1" });
} catch { /* provider may fail on mock, that's ok */ }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBe(true);
});
it("does not set disableHistory when not configured", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
try {
await provider.search("test", { user_id: "u1" });
} catch { }
expect(capturedConfig).toBeDefined();
expect(capturedConfig!.disableHistory).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 3. OSSProvider: initPromise is cleared on failure (allows retry)
// ---------------------------------------------------------------------------
describe("OSSProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("SQLITE_CANTOPEN: simulated binding failure");
}
// Second+ call succeeds
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call: _init throws, but initPromise is cleared so retry is possible
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("SQLITE_CANTOPEN");
// Second call: should retry _init (not return cached rejection)
// callCount === 1 threw, so callCount === 2 should succeed
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
// ---------------------------------------------------------------------------
// 4. OSSProvider: graceful fallback disables history on init failure
// ---------------------------------------------------------------------------
describe("OSSProvider — graceful SQLite fallback", () => {
let capturedConfigs: Record<string, unknown>[];
beforeEach(() => {
capturedConfigs = [];
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
capturedConfigs.push({ ...config });
if (!config.disableHistory) {
throw new Error("Could not locate the bindings file");
}
// Succeeds when disableHistory is true
}
async search() { return { results: [] }; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries with disableHistory: true when initial construction fails", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: {},
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should succeed — first attempt fails, fallback with disableHistory succeeds
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
// Memory constructor was called twice
expect(capturedConfigs).toHaveLength(2);
expect(capturedConfigs[0].disableHistory).toBeFalsy();
expect(capturedConfigs[1].disableHistory).toBe(true);
// Warning was logged
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[mem0] Memory initialization failed"),
expect.stringContaining("bindings file"),
);
warnSpy.mockRestore();
});
it("does not retry when disableHistory is already true", async () => {
vi.doMock("mem0ai/oss", () => ({
Memory: class MockMemory {
constructor(config: Record<string, unknown>) {
// Fail even with disableHistory (e.g. vector store issue)
throw new Error("vector store connection refused");
}
},
}));
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "open-source",
oss: { disableHistory: true },
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// Should throw — no fallback possible when disableHistory was already set
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("vector store connection refused");
});
});
// ---------------------------------------------------------------------------
// 5. PlatformProvider — initPromise retry after failure
// ---------------------------------------------------------------------------
describe("PlatformProvider — initPromise retry after failure", () => {
let callCount: number;
beforeEach(() => {
callCount = 0;
vi.doMock("mem0ai", () => ({
default: class MockMemoryClient {
constructor() {
callCount++;
if (callCount === 1) {
throw new Error("Network timeout");
}
}
async search() { return []; }
async get() { return {}; }
async getAll() { return []; }
async add() { return { results: [] }; }
async delete() { }
},
}));
});
it("retries initialization after a transient failure", async () => {
const { createProvider } = await import("./index.ts");
const cfg = mem0ConfigSchema.parse({
mode: "platform",
apiKey: "test-api-key",
});
const api = { resolvePath: (p: string) => p } as any;
const provider = createProvider(cfg, api);
// First call fails
await expect(
provider.search("test", { user_id: "u1" }),
).rejects.toThrow("Network timeout");
// Second call should retry (not return cached rejection)
const results = await provider.search("test", { user_id: "u1" });
expect(results).toBeDefined();
expect(callCount).toBe(2);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.5"
version = "1.0.6"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }
+6
View File
@@ -13,6 +13,12 @@ When installed, Claude can:
## Installation
### CLI (Claude Code, OpenCode, OpenClaw, or any tool that supports skills)
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0
```
### Claude.ai
1. Download this `skills/mem0` folder as a ZIP
+214
View File
@@ -0,0 +1,214 @@
"""
Tests for issue #3559: Custom prompts crash with response_format json_object
when the word 'json' is not present in the prompt.
OpenAI API requires the word 'json' to appear in messages when using
response_format: {"type": "json_object"}. Custom fact extraction prompts
may not include this word, causing BadRequestError.
This tests the ensure_json_instruction utility function and verifies
the fix is applied in both sync and async code paths.
"""
import pytest
from mem0.memory.utils import ensure_json_instruction
class TestEnsureJsonInstruction:
"""Tests for the ensure_json_instruction utility function."""
# -------------------------------------------------------------------
# Core behavior: append when missing, skip when present
# -------------------------------------------------------------------
def test_appends_when_json_missing_from_both_prompts(self):
"""When neither prompt contains 'json', instruction is appended to system prompt."""
system, user = ensure_json_instruction(
"Extract facts from the conversation and return them as a list.",
"Input:\nuser: Hi my name is John",
)
assert "json" in system.lower()
assert "facts" in system.lower()
def test_no_change_when_json_in_system_prompt(self):
"""When system prompt already contains 'json', no modification."""
original = "Extract facts and return in json format."
system, user = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_no_change_when_json_in_user_prompt(self):
"""When user prompt contains 'json', no modification to system prompt."""
original_system = "Extract facts from the conversation."
original_user = "Input (respond in json):\nuser: Hi"
system, user = ensure_json_instruction(original_system, original_user)
assert system == original_system
def test_user_prompt_never_modified(self):
"""The user prompt should never be modified regardless of content."""
original_user = "Input:\nuser: I like pizza"
_, user = ensure_json_instruction("Extract facts.", original_user)
assert user == original_user
# -------------------------------------------------------------------
# Case insensitivity
# -------------------------------------------------------------------
def test_case_insensitive_lowercase(self):
original = "Return results in json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_uppercase(self):
original = "Return results in JSON format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_mixed(self):
original = "Return results in Json format."
system, _ = ensure_json_instruction(original, "Input:\nuser: Hi")
assert system == original
def test_case_insensitive_in_user_prompt(self):
original_system = "Extract facts."
system, _ = ensure_json_instruction(original_system, "Return JSON.\nuser: Hi")
assert system == original_system
# -------------------------------------------------------------------
# Parametrized: various custom prompts
# -------------------------------------------------------------------
@pytest.mark.parametrize(
"prompt,should_append",
[
# Prompts WITHOUT json — should append
("Extract all facts from the conversation.", True),
("You are a memory extractor. Return facts as a list.", True),
("Analyze the input and find key information.", True),
("Return data in structured format.", True),
("List the user preferences.", True),
# Prompts WITH json — should NOT append
("Extract facts and return in json format.", False),
("Return a json object with facts.", False),
("Output must be valid JSON.", False),
("Respond with a JSON array of facts.", False),
("Format: json output expected.", False),
],
)
def test_various_custom_prompts(self, prompt, should_append):
user_prompt = "Input:\nuser: Hi my name is John"
system, _ = ensure_json_instruction(prompt, user_prompt)
if should_append:
assert system != prompt, f"Expected JSON instruction to be appended for: {prompt}"
assert "json" in system.lower()
else:
assert system == prompt, f"Did not expect modification for: {prompt}"
# -------------------------------------------------------------------
# Edge cases
# -------------------------------------------------------------------
def test_empty_system_prompt(self):
"""Empty system prompt should get JSON instruction."""
system, _ = ensure_json_instruction("", "Input:\nuser: test")
assert "json" in system.lower()
def test_whitespace_only_system_prompt(self):
"""Whitespace-only prompt should get JSON instruction."""
system, _ = ensure_json_instruction(" \n ", "Input:\nuser: test")
assert "json" in system.lower()
def test_preserves_original_prompt_content(self):
"""The fix should only append, never modify the original prompt content."""
original = "Extract all user preferences and habits from the conversation."
system, _ = ensure_json_instruction(original, "Input:\nuser: I like pizza")
assert system.startswith(original)
assert len(system) > len(original)
def test_appended_instruction_mentions_facts_key(self):
"""The appended instruction should guide the model to use the 'facts' key."""
system, _ = ensure_json_instruction(
"Extract information.", "Input:\nuser: test"
)
assert "facts" in system.lower()
def test_idempotent_when_already_has_json(self):
"""Calling ensure_json_instruction twice doesn't double-append."""
system1, user1 = ensure_json_instruction(
"Extract facts.", "Input:\nuser: test"
)
system2, user2 = ensure_json_instruction(system1, user1)
assert system1 == system2
assert user1 == user2
def test_json_in_curly_braces_not_detected(self):
"""A prompt with JSON-like structure but no 'json' word should get instruction.
e.g. '{"facts": [...]}' contains the characters j,s,o,n but not the word 'json'."""
prompt = 'Return format: {"facts": [...]}'
# This contains the substring "json" inside the key name — let's check
if "json" in prompt.lower():
# If it does contain json, it won't be modified
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt
else:
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system != prompt
# -------------------------------------------------------------------
# Default prompts verification
# -------------------------------------------------------------------
def test_default_prompts_already_contain_json(self):
"""Built-in prompts already contain 'json', so ensure_json_instruction is a no-op."""
from mem0.configs.prompts import (
FACT_RETRIEVAL_PROMPT,
USER_MEMORY_EXTRACTION_PROMPT,
AGENT_MEMORY_EXTRACTION_PROMPT,
)
for name, prompt in [
("FACT_RETRIEVAL_PROMPT", FACT_RETRIEVAL_PROMPT),
("USER_MEMORY_EXTRACTION_PROMPT", USER_MEMORY_EXTRACTION_PROMPT),
("AGENT_MEMORY_EXTRACTION_PROMPT", AGENT_MEMORY_EXTRACTION_PROMPT),
]:
assert "json" in prompt.lower(), (
f"{name} should contain 'json' — "
"if this fails, the default prompts have changed"
)
# ensure_json_instruction should be a no-op for defaults
system, _ = ensure_json_instruction(prompt, "Input:\nuser: test")
assert system == prompt, f"ensure_json_instruction modified {name} unexpectedly"
# -------------------------------------------------------------------
# Integration: verify fix is wired into both sync and async paths
# -------------------------------------------------------------------
def test_fix_applied_in_sync_memory_class(self):
"""Verify the ensure_json_instruction call exists in Memory._add_to_vector_store."""
import inspect
from mem0.memory.main import Memory
source = inspect.getsource(Memory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in Memory._add_to_vector_store (sync)"
)
def test_fix_applied_in_async_memory_class(self):
"""Verify the ensure_json_instruction call exists in AsyncMemory._add_to_vector_store."""
import inspect
from mem0.memory.main import AsyncMemory
source = inspect.getsource(AsyncMemory._add_to_vector_store)
assert "ensure_json_instruction" in source, (
"ensure_json_instruction not found in AsyncMemory._add_to_vector_store (async)"
)
def test_import_exists_in_main(self):
"""Verify ensure_json_instruction is imported in main.py."""
import inspect
import mem0.memory.main as main_module
source = inspect.getsource(main_module)
assert "from mem0.memory.utils import" in source
assert "ensure_json_instruction" in source
+45 -1
View File
@@ -1,6 +1,8 @@
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import pytest
from unittest.mock import Mock, patch
from mem0.memory.kuzu_memory import MemoryGraph
@@ -190,6 +192,48 @@ class TestKuzu:
assert get_node_count(kuzu_memory) == 0
assert get_edge_count(kuzu_memory) == 0
def _make_kuzu_instance():
with patch.object(MemoryGraph, "__init__", return_value=None):
instance = MemoryGraph.__new__(MemoryGraph)
instance.llm_provider = "openai"
instance.llm = MagicMock()
instance.embedding_model = MagicMock()
instance.config = MagicMock()
instance.config.graph_store.custom_prompt = None
return instance
class TestRetrieveNodesFromData:
"""Tests for _retrieve_nodes_from_data in KuzuMemoryGraph."""
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_normal_entities_extracted(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"entities": [
{"entity": "Alice", "entity_type": "person"},
{"entity": "hiking", "entity_type": "activity"},
]}}]
}
result = instance._retrieve_nodes_from_data("Alice loves hiking", {"user_id": "u1"})
assert result == {"alice": "person", "hiking": "activity"}
def test_none_tool_calls_returns_empty(self):
instance = _make_kuzu_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
result = instance._retrieve_nodes_from_data("hello world", {"user_id": "u1"})
assert result == {}
def get_node_count(kuzu_memory):
results = kuzu_memory.kuzu_execute(
"""
+11
View File
@@ -10,6 +10,7 @@ patch.dict("sys.modules", {
}).start()
from mem0.memory.memgraph_memory import MemoryGraph as MemgraphMemoryGraph # noqa: E402
MemoryGraph = MemgraphMemoryGraph
@@ -54,6 +55,16 @@ class TestRetrieveNodesFromData:
assert "relu" in result
assert "task" not in result
def test_missing_entities_key_returns_empty(self):
"""LLM returns extract_entities tool call without 'entities' key — should not crash.
Reproduces the exact scenario from issue #4238."""
instance = _make_instance()
instance.llm.generate_response.return_value = {
"tool_calls": [{"name": "extract_entities", "arguments": {"text": "Hello."}}]
}
result = instance._retrieve_nodes_from_data("Hello.", {"user_id": "u1"})
assert result == {}
def test_none_tool_calls_returns_empty(self):
instance = _make_instance()
instance.llm.generate_response.return_value = {"tool_calls": None}
+44 -1
View File
@@ -60,7 +60,7 @@ def memory_custom_instance():
config = MemoryConfig(
version="v1.1",
custom_fact_extraction_prompt="custom prompt extracting memory",
custom_fact_extraction_prompt="custom prompt extracting memory in json format",
custom_update_memory_prompt="custom prompt determining memory update",
)
config.graph_store.config = {"some_config": "value"}
@@ -196,12 +196,15 @@ def test_delete_all(memory_instance, version, enable_graph):
memory_instance.enable_graph = enable_graph
mock_memories = [Mock(id="1"), Mock(id="2")]
memory_instance.vector_store.list = Mock(return_value=(mock_memories, None))
memory_instance.vector_store.reset = Mock()
memory_instance._delete_memory = Mock()
memory_instance.graph.delete_all = Mock()
result = memory_instance.delete_all(user_id="test_user")
assert memory_instance._delete_memory.call_count == 2
# Ensure the collection is NOT dropped — only matched memories should be removed
memory_instance.vector_store.reset.assert_not_called()
if enable_graph:
memory_instance.graph.delete_all.assert_called_once_with({"user_id": "test_user"})
@@ -296,3 +299,43 @@ def test_custom_prompts(memory_custom_instance):
messages=[{"role": "user", "content": mock_get_update_memory_messages.return_value}],
response_format={"type": "json_object"},
)
def test_no_telemetry_vector_store_when_disabled():
"""VectorStoreFactory should only be called once (for user data) when telemetry is disabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", False),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
config = MemoryConfig(version="v1.1")
Memory(config)
# VectorStoreFactory.create should be called exactly once — for user data only, not telemetry
assert mock_vector_store.create.call_count == 1
def test_telemetry_vector_store_created_when_enabled():
"""VectorStoreFactory should be called twice (user data + telemetry) when telemetry is enabled."""
with (
patch("mem0.memory.main.MEM0_TELEMETRY", True),
patch("mem0.utils.factory.EmbedderFactory") as mock_embedder,
patch("mem0.memory.main.VectorStoreFactory") as mock_vector_store,
patch("mem0.utils.factory.LlmFactory") as mock_llm,
patch("mem0.memory.telemetry.capture_event"),
):
mock_embedder.create.return_value = Mock()
mock_vector_store.create.return_value = Mock()
mock_llm.create.return_value = Mock()
config = MemoryConfig(version="v1.1")
Memory(config)
# VectorStoreFactory.create should be called twice — user data + telemetry
assert mock_vector_store.create.call_count == 2