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
Saket AryanandGitHub 06ee1b588c fix(openclaw): point plugin extension entry to built output for npm compatibility (#4340) 2026-03-15 04:41:11 +05:30
Saket AryanandGitHub dc6122ec3d chore(openclaw): add tsup build pipeline with ESM output and type declarations (#4335) 2026-03-15 00:41:07 +05:30
39 changed files with 5841 additions and 102 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
View File
@@ -0,0 +1,2 @@
package-manager-strict-version=false
approve-builds=esbuild
+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 |
+1 -1
View File
@@ -142,7 +142,7 @@ class PlatformProvider implements Mem0Provider {
private async _init(): Promise<void> {
const { default: MemoryClient } = await import("mem0ai");
const opts: Record<string, string> = { apiKey: this.apiKey };
const opts: { apiKey: string; org_id?: string; project_id?: string } = { apiKey: this.apiKey };
if (this.orgId) opts.org_id = this.orgId;
if (this.projectId) opts.project_id = this.projectId;
this.client = new MemoryClient(opts);
+30
View File
@@ -0,0 +1,30 @@
declare module "openclaw/plugin-sdk" {
export interface OpenClawPluginApi {
pluginConfig: Record<string, unknown>;
logger: {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
debug(msg: string): void;
};
resolvePath(p: string): string;
registerTool(
definition: Record<string, unknown>,
metadata?: Record<string, unknown>,
): void;
on(
event: string,
handler: (event: any, ctx: any) => any,
): void;
registerCli(
handler: (context: { program: any }) => void,
options?: Record<string, unknown>,
): void;
registerService(service: {
id: string;
start: () => void;
stop: () => void;
}): void;
[key: string]: unknown;
}
}
+19 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "0.3.1",
"version": "0.3.3",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -11,7 +11,20 @@
"mem0",
"long-term-memory"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"openclaw.plugin.json"
],
"scripts": {
"build": "tsup",
"test": "vitest run"
},
"dependencies": {
@@ -20,10 +33,14 @@
},
"openclaw": {
"extensions": [
"./index.ts"
"./dist/index.js"
]
},
"devDependencies": {
"@types/node": "^22.15.0",
"@vitest/coverage-v8": "^4.0.18",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^4.0.18"
}
}
+4222
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
packages:
- '.'
onlyBuiltDependencies:
- better-sqlite3
- esbuild
- protobufjs
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": ".",
"strict": false,
"noImplicitAny": false,
"types": ["node"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["index.ts", "openclaw-plugin-sdk.d.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
clean: true,
});
+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