Compare commits

..

2 Commits

Author SHA1 Message Date
harshgupta-mem0 884e056853 fix(deps): upgrade litellm to >=1.88.1 and python-dotenv to 1.2.2 (CVE-2026-28684)
litellm 1.83.7 hard-pinned python-dotenv==1.0.1 (vulnerable). litellm 1.88.1
relaxed this to python-dotenv>=1.0.0,<2.0, unblocking the upgrade to 1.2.2.

Updates pyproject.toml constraint and manually patches poetry.lock entries
with verified PyPI hashes. The lock file content-hash will need a full
poetry lock regeneration on Python 3.10-3.13 (litellm does not yet support
Python 3.14, which is the local system Python).

Resolves Dependabot alert #760.
2026-06-12 20:08:58 +05:30
harshgupta-mem0 fbf6b8c0db fix(@mem0/community): upgrade @langchain/community to ^1.1.18 (CVE-2026-27795, CVE-2026-26019)
Bumps @langchain/community from ^0.3.36 to ^1.1.18 and @langchain/core from
^0.3.42 to ^1.1.27 to resolve two SSRF CVEs in RecursiveUrlLoader.

Resolves Dependabot alerts #496 and #538.
2026-06-12 19:48:13 +05:30
85 changed files with 244 additions and 11045 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
"version": "0.2.10"
"version": "0.2.9"
}
]
}
+1 -1
View File
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/mem0-plugin",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
"version": "0.2.10"
"version": "0.2.9"
}
]
}
+4 -3
View File
@@ -29,11 +29,12 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
| `server/` | FastAPI REST server for self-hosted Mem0 (Docker: FastAPI + PostgreSQL/pgvector + Neo4j) |
| `openmemory/` | Self-hosted memory platform — `api/` (FastAPI + Alembic + MCP server) and `ui/` (Next.js 15 + React 19) |
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/`, `mem0-oss-to-platform/` |
| `skills/` | Claude Code skill definitions. Reference skills (SDK knowledge, always-on): `mem0/`, `mem0-cli/`, `mem0-vercel-ai-sdk/`. Pipeline skills (run on demand): `mem0-integrate/`, `mem0-test-integration/` |
| `docs/` | Documentation site (Mintlify) |
| `tests/` | Python SDK tests (pytest) |
| `evaluation/` | Benchmarking framework — LOCOMO evals, experiment runner, score generation |
| `examples/` | Sample projects & runnable demos — apps, Chrome extension, multi-agent patterns, and Jupyter notebooks (`notebooks/`) |
| `examples/` | Sample projects — demo apps, Chrome extension, multi-agent patterns |
| `cookbooks/` | Jupyter notebooks — customer support chatbot, AutoGen integration |
| `pr-reviews/` | Pull request review materials |
| `scripts/` | Repo-wide utility scripts (e.g., `check-llms-txt-coverage.py` for docs/llms.txt sync) |
@@ -389,7 +390,7 @@ Model Context Protocol support in multiple places:
- `integrations/mem0-plugin/` provides integrations for Claude Code, Cursor, and Codex via MCP server connections and lifecycle hooks for automatic memory capture.
- `skills/` contains structured skill definitions for AI agents, split into two categories:
- **Reference skills** (always-on SDK knowledge): `mem0` (Python + TS SDKs, framework integrations), `mem0-cli` (terminal workflows), `mem0-vercel-ai-sdk` (Vercel AI provider).
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch (the two are loosely coupled via `.mem0-integration/` artifacts); `mem0-oss-to-platform` migrates an existing project from Mem0 OSS to the hosted Platform SDK (plan, then execute on approval).
- **Pipeline skills** (run on demand): `mem0-integrate` wires Mem0 into an existing repo via a TDD pipeline; `mem0-test-integration` verifies what the integrator produced on the same branch. The two are loosely coupled via `.mem0-integration/` artifacts.
### Adding a New Provider
+1 -2
View File
@@ -1026,8 +1026,7 @@ def get_user_preferences(user_id: str):
### AutoGen Integration
```python
# Mem0Teachability lives in examples/notebooks/helper/ — see examples/notebooks/mem0-autogen.ipynb
from helper.mem0_teachability import Mem0Teachability
from cookbooks.helper.mem0_teachability import Mem0Teachability
from mem0 import Memory
# Add memory capability to AutoGen agents
+1 -2
View File
@@ -186,10 +186,9 @@ npx skills add https://github.com/mem0ai/mem0 --skill mem0-vercel-ai-sdk
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
```
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. Use `/mem0-oss-to-platform` to migrate an existing project from Mem0 OSS to the hosted Platform SDK. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
Use `/mem0-integrate` to wire Mem0 into an existing repo via a test-first pipeline, then `/mem0-test-integration` to verify. See the [skills catalog](./skills/) or [Vibecoding with Mem0](https://docs.mem0.ai/vibecoding) for the full picture.
### Basic Usage
@@ -555,7 +555,7 @@
"# - Enables creation of AI agents with long-term memory and learning abilities.\n",
"# - Improves consistency and reduces repetition in user-agent interactions.\n",
"\n",
"from helper.mem0_teachability import Mem0Teachability\n",
"from cookbooks.helper.mem0_teachability import Mem0Teachability\n",
"\n",
"teachability = Mem0Teachability(\n",
" verbosity=2, # for visibility of what's happening\n",
-33
View File
@@ -4,39 +4,6 @@ description: "Release notes for the OpenClaw plugin and agent harness."
mode: "wide"
---
<Update label="2026-06-12" description="v1.0.13">
**Fixes:**
- **Custom categories payload:** `customCategories` (a `Record<string, string>` map) is now converted via the new `customCategoryMapToList()` helper into the `Array<Record<string, string>>` shape the Mem0 SDK expects on `add` calls — previously the raw object was passed as `custom_categories` and silently ignored ([#5345](https://github.com/mem0ai/mem0/pull/5345))
- **Skip runtime setup during metadata registration:** `register()` now detects `registrationMode === "cli-metadata"`, registers only the CLI commands, and returns early — avoiding backend initialization, service/tool registration, and hook installation during OpenClaw's metadata-only registration pass ([#5383](https://github.com/mem0ai/mem0/pull/5383))
**Security:**
- Bumped `mem0ai` from `3.0.3` to `3.0.7` (latest Node SDK) — includes the transitive axios CVE remediation shipped in `3.0.6` ([#5460](https://github.com/mem0ai/mem0/pull/5460))
- Added pnpm override `uuid@<11.1.1` → `>=11.1.1` to resolve an open MEDIUM Dependabot alert ([#5489](https://github.com/mem0ai/mem0/pull/5489))
**Improvements:**
- **Repo consolidation:** Plugin moved from repo-root `openclaw/` to `integrations/openclaw/`; `package.json` `repository.directory` updated to match so npm provenance links to the correct subdirectory ([#5491](https://github.com/mem0ai/mem0/pull/5491))
**Tests:**
- Added `customCategoryMapToList` unit tests and a `PlatformProvider` test asserting `custom_categories` is passed to the Mem0 SDK as a list ([#5345](https://github.com/mem0ai/mem0/pull/5345))
- Added a regression test asserting `cli-metadata` registration registers only CLI commands and triggers no runtime side effects ([#5383](https://github.com/mem0ai/mem0/pull/5383))
</Update>
<Update label="2026-06-02" description="v1.0.12">
**Docs:**
- **Agent Mode onboarding:** README now documents an autonomous setup path for AI agents — `mem0 init --agent --json` mints an evaluation Mem0 API key with no email, OTP, or browser and exports it as `MEM0_API_KEY` for `openclaw mem0 init`; a human owner can later run `mem0 init --email <email>` to claim ownership without disrupting the agent ([#5123](https://github.com/mem0ai/mem0/pull/5123))
**Security:**
- Added pnpm overrides to remediate advisories in transitive dependencies: `langsmith@<0.6.0` → `^0.6.0`, `picomatch@<2.3.2` → `^2.3.2`, `vite` → `^8.0.5`, and `@qdrant/js-client-rest` → `^1.18.0` ([#5294](https://github.com/mem0ai/mem0/pull/5294))
**Dependencies:**
- Bumped `mem0ai` from `3.0.2` to `3.0.3` ([#5212](https://github.com/mem0ai/mem0/pull/5212))
- Bumped dev dependencies `@vitest/coverage-v8` and `vitest` from `^4.0.18` to `^4.1.7`; added `vite@^8.0.5` and `@qdrant/js-client-rest@^1.18.0` ([#5294](https://github.com/mem0ai/mem0/pull/5294))
</Update>
<Update label="2026-04-29" description="v1.0.11">
**New Features:**
-27
View File
@@ -7,22 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-06-13" description="v2.0.6">
**New Features:**
- **Memory:** Add a contextual OSS-to-Platform notices system that surfaces occasional, situation-aware messages (first run, scale/performance thresholds, slow queries, and when temporal/decay features are relevant) pointing to the corresponding Mem0 Platform capabilities; disable via `MEM0_TELEMETRY=false` ([#5494](https://github.com/mem0ai/mem0/pull/5494))
**Bug Fixes:**
- **Memory:** Prevent a crash in `parse_vision_messages` when vision support is disabled ([#5487](https://github.com/mem0ai/mem0/pull/5487))
- **Vector Stores:** Expose the `https` option on the Qdrant vector store configuration so TLS endpoints can be targeted explicitly ([#5380](https://github.com/mem0ai/mem0/pull/5380))
- **Vector Stores:** Use valid S3 Vectors entity index names, fixing index operations that failed on invalid names ([#5416](https://github.com/mem0ai/mem0/pull/5416))
- **Vector Stores:** Fix `search()` crashing with a `TypeError` in the LangChain vector store when a result score is `None` ([#5072](https://github.com/mem0ai/mem0/pull/5072))
- **Vector Stores:** Use `is not None` instead of a truthiness check for vector/payload in the PGVector `update()` path, so valid empty/zero values are no longer skipped ([#5488](https://github.com/mem0ai/mem0/pull/5488))
- **Vector Stores:** Index the Valkey `memory` field as `TEXT` rather than `TAG` so full-text search behaves correctly ([#5443](https://github.com/mem0ai/mem0/pull/5443))
- **Vector Stores:** Implement `$not` filter support in the ChromaDB vector store ([#5485](https://github.com/mem0ai/mem0/pull/5485))
</Update>
<Update label="2026-06-10" description="v2.0.5">
**New Features:**
@@ -976,17 +960,6 @@ See the [OSS v1 to v2 migration guide](https://docs.mem0.ai/migration/oss-v1-to-
<Tab title="TypeScript">
<Update label="2026-06-13" description="v3.0.8">
**New Features:**
- **Memory:** Add a contextual OSS-to-Platform notices system that surfaces occasional, situation-aware messages (first run, scale/performance thresholds, slow queries, and when temporal/decay features are relevant) pointing to the corresponding Mem0 Platform capabilities; disable via `MEM0_TELEMETRY=false` ([#5494](https://github.com/mem0ai/mem0/pull/5494))
**Security:**
- **Dependencies:** Upgrade `@langchain/community` to `^1.1.18` to remediate CVE-2026-27795 and CVE-2026-26019 ([#5510](https://github.com/mem0ai/mem0/pull/5510))
- **Dependencies:** Resolve all open MEDIUM Dependabot alerts via pnpm overrides ([#5489](https://github.com/mem0ai/mem0/pull/5489))
</Update>
<Update label="2026-06-10" description="v3.0.7">
**New Features:**
+1 -2
View File
@@ -76,7 +76,6 @@ Let's see the available parameters for the `qdrant` config:
| `path` | Path for the qdrant database | `/tmp/qdrant` |
| `url` | Full URL for the qdrant server | `None` |
| `api_key` | API key for the qdrant server | `None` |
| `https` | Whether to force HTTPS on or off. `None` lets the client decide; set `False` for plain HTTP Qdrant with API key authentication. | `None` |
| `on_disk` | For enabling persistent storage | `False` |
</Tab>
<Tab title="TypeScript">
@@ -91,4 +90,4 @@ Let's see the available parameters for the `qdrant` config:
| `apiKey` | API key for the Qdrant server | `None` |
| `onDisk` | For enabling persistent storage | `False` |
</Tab>
</Tabs>
</Tabs>
+4 -19
View File
@@ -6,7 +6,9 @@ versionFrom: "Open Source"
versionTo: "Platform"
---
## Overview
# Migrate from Open Source to Platform
Move your Mem0 implementation to managed infrastructure with enterprise features.
| Scope | Effort | Downtime |
| --------------------- | -------------- | ---------------------------- |
@@ -28,29 +30,12 @@ versionTo: "Platform"
- **Production Grade**: Auto-scaling, high availability, dedicated support
</Info>
### Plan
## Plan
1. **Sign up**: Create an account on <a href="https://app.mem0.ai?utm_source=oss&utm_medium=migration-oss-to-platform" rel="nofollow">Mem0 Platform</a>.
2. **Get API Key**: Navigate to **Settings > API Keys** and generate a new key.
3. **Review Usage**: Identify where you instantiate `Memory` and where you call `search` or `get_all`.
## Migrate with Agent Skill
Paste this prompt into your coding agent. It uses a migration skill to produce a plan; once you review and approve it, the agent implements the changes.
```text
Migrate my project from Mem0 OSS to the Mem0 Platform SDK using the
mem0-oss-to-platform skill in the mem0ai/mem0 repo, at
skills/mem0-oss-to-platform/
Get the skill whichever way is easiest:
- install it: npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
- if the mem0 repo is cloned locally, read it from skills/mem0-oss-to-platform/
- otherwise fetch that folder from github.com/mem0ai/mem0 (SKILL.md + references/)
Then read SKILL.md and begin the migration.
```
## Migrate
### 1. Import Memories Into Platform
-2
View File
@@ -45,12 +45,10 @@ Let your assistant execute an end-to-end workflow in an existing repo. Invoked a
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate
npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration
npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
```
- `/mem0-integrate` — wire Mem0 into an existing repository using a goal-driven, test-first pipeline. Detects the stack, asks whether to use Platform or OSS, writes failing tests first, and keeps the integration additive and feature-flagged.
- `/mem0-test-integration` — verify what `/mem0-integrate` produced. Runs the repo's native test suite and a real end-to-end smoke flow against your API key, then produces a scorecard.
- `/mem0-oss-to-platform` — migrate an existing project from Mem0 OSS to the hosted Platform SDK. Audits where Mem0 is used, writes a reviewable migration plan, then executes it on approval.
See the [skills index](https://github.com/mem0ai/mem0/tree/main/skills) for the full catalog.
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.10",
"version": "0.2.9",
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
"author": {
"name": "Mem0",
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.10",
"version": "0.2.9",
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
"author": {
"name": "Mem0",
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.10",
"version": "0.2.9",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
@@ -2,14 +2,13 @@
All notable changes to the `@mem0/opencode-plugin` will be documented in this file.
## 0.1.3 — File-context injection, session summaries & activity timeline, anonymous telemetry
## 0.1.3 — File-context injection, session summaries & activity timeline
### Added
- **File-context injection (`tool.execute.before` / Read):** Before the agent reads a file, the plugin searches mem0 for memories referencing that file path and injects prior work as system context. Gates on file size (>= 1,500 bytes). Gives the agent "I've worked on this file before" awareness automatically.
- **Stop hook session summary (`experimental.session.compacting`):** Enhanced session compaction to store a structured `session_summary` memory with `infer=True`, letting the mem0 backend AI extract key facts (request, decisions, learnings, next steps). Previously only stored a raw stats string.
- **SessionStart activity timeline:** The initial memory loading now formats recent memories with type icons (⚖️ decision, 🔴 bug_fix, 🔵 task_learning, etc.) and relative age indicators (2h ago, 1d ago) instead of bare text. Provides a visual "Recent Activity" timeline on first message.
- **PostHog telemetry (`telemetry.ts`):** Anonymous, fire-and-forget usage events. Opt out with `MEM0_TELEMETRY=false`. Only fires when an API key is present; never sends memory content, prompts, or the API key — only an anonymized `sha256(apiKey)[:32]` identity plus event type, platform, and plugin version. Emits the same schema as the Mem0 editor plugin (`plugin.*` events, `source: "plugin"`, `platform: "opencode"`) so OpenCode appears as a `platform` in the shared plugin dashboard. Events: `plugin.session_start` (with memory count) and `plugin.tool_use` (`add` / `search` / `update` / `delete`).
### Changed
@@ -6,7 +6,7 @@
"name": "@mem0/opencode-plugin",
"dependencies": {
"@opencode-ai/plugin": "^1.0.162",
"mem0ai": "^3.0.7",
"mem0ai": "^3.0.5",
},
"devDependencies": {
"bun-types": ">=1.3.14",
@@ -462,7 +462,7 @@
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
"mem0ai": ["mem0ai@3.0.7", "", { "dependencies": { "axios": "^1.16.0", "openai": "^4.93.0", "uuid": "9.0.1", "zod": "^3.24.1" }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", "@google/genai": "^1.40.0", "@langchain/core": "^1.1.47", "@mistralai/mistralai": "^1.5.2", "@qdrant/js-client-rest": "^1.18.0", "@supabase/supabase-js": "^2.49.1", "@types/jest": "29.5.14", "@types/pg": "8.11.0", "better-sqlite3": "^12.6.2", "cloudflare": "^4.2.0", "compromise": "^14.0.0", "groq-sdk": "0.3.0", "natural": "^8.0.1", "ollama": "^0.5.14", "pg": "8.11.3", "redis": "^4.6.13" } }, "sha512-CUHzX7DyeKTHcI3aDsSqY9LXTD7GcFxf988796TuOa4yJgGuF2Xd2NROcBhVFRo3r9y8fVmbo3c5TF9jv1KlKw=="],
"mem0ai": ["mem0ai@3.0.5", "", { "dependencies": { "axios": "^1.15.2", "openai": "^4.93.0", "uuid": "9.0.1", "zod": "^3.24.1" }, "peerDependencies": { "@anthropic-ai/sdk": "^0.40.1", "@azure/identity": "^4.0.0", "@azure/search-documents": "^12.0.0", "@cloudflare/workers-types": "^4.20250504.0", "@google/genai": "^1.2.0", "@langchain/core": "^1.1.47", "@mistralai/mistralai": "^1.5.2", "@qdrant/js-client-rest": "1.13.0", "@supabase/supabase-js": "^2.49.1", "@types/jest": "29.5.14", "@types/pg": "8.11.0", "better-sqlite3": "^12.6.2", "cloudflare": "^4.2.0", "compromise": "^14.0.0", "groq-sdk": "0.3.0", "natural": "^8.0.1", "ollama": "^0.5.14", "pg": "8.11.3", "redis": "^4.6.13" } }, "sha512-W/R59d5fMpUGHhPEnyoo36GSz5NFJbAs+vS4BxoIvE+t19mIJfoz/2FJSKIw80mT8AkeNYpDfzc/DYRu2IzYIw=="],
"memjs": ["memjs@1.3.2", "", {}, "sha512-qUEg2g8vxPe+zPn09KidjIStHPtoBO8Cttm8bgJFWWabbsjQ9Av9Ky+6UcvKx6ue0LLb/LEhtcyQpRyKfzeXcg=="],
@@ -9,7 +9,6 @@ import { existsSync, readdirSync, cpSync, mkdirSync, readFileSync, writeFileSync
import { homedir } from "os";
import { join } from "path";
import { createHash } from "crypto";
import { captureEvent } from "./telemetry";
async function getUserId(): Promise<string> {
if (process.env.MEM0_USER_ID) return process.env.MEM0_USER_ID;
@@ -369,8 +368,6 @@ const Mem0Plugin: Plugin = async (ctx) => {
});
} catch {}
}
captureEvent("session_start", { memory_count: memoryCount }, apiKey);
}
if (NUDGE_RE.test(safeText)) {
@@ -605,17 +602,6 @@ const Mem0Plugin: Plugin = async (ctx) => {
if (MEM0_MCP_RE.test(toolName)) {
if (toolName.includes("add_memory")) stats.adds++;
if (toolName.includes("search")) stats.searches++;
const tool = toolName.includes("add_memory")
? "add_memory"
: toolName.includes("search")
? "search_memories"
: toolName.includes("delete")
? "delete_memory"
: toolName.includes("update")
? "update_memory"
: "other";
captureEvent("tool_use", { tool }, apiKey);
}
if (toolName === "bash" && toolOutput.length >= 50) {
@@ -59,7 +59,7 @@
},
"dependencies": {
"@opencode-ai/plugin": "^1.0.162",
"mem0ai": "^3.0.7"
"mem0ai": "^3.0.5"
},
"devDependencies": {
"bun-types": ">=1.3.14",
@@ -1,51 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test";
import { buildEvent, captureEvent, isTelemetryEnabled } from "./telemetry";
const KEY = "m0-testkey123";
afterEach(() => {
delete process.env.MEM0_TELEMETRY;
});
describe("opencode telemetry", () => {
test("buildEvent uses the shared plugin.* schema with platform=opencode", () => {
const payload = buildEvent("session_start", { memory_count: 5 }, KEY);
expect(payload).not.toBeNull();
const props = payload!.properties as Record<string, unknown>;
expect(payload!.event).toBe("plugin.session_start");
expect(props.source).toBe("plugin");
expect(props.platform).toBe("opencode");
expect(props.memory_count).toBe(5);
expect(props.$process_person_profile).toBe(false);
expect(typeof props.plugin_version).toBe("string");
});
test("distinct_id is sha256(apiKey)[:32] — matches the editor plugin", async () => {
const { createHash } = await import("node:crypto");
const expected = createHash("sha256").update(KEY).digest("hex").slice(0, 32);
expect(buildEvent("session_start", {}, KEY)!.distinct_id).toBe(expected);
});
test("system properties win over caller-supplied ones", () => {
const props = buildEvent("x", { platform: "HACK", source: "HACK" }, KEY)!
.properties as Record<string, unknown>;
expect(props.platform).toBe("opencode");
expect(props.source).toBe("plugin");
});
test("returns null without an API key (no anonymous events)", () => {
expect(buildEvent("session_start", {}, undefined)).toBeNull();
});
test("opt-out via MEM0_TELEMETRY disables events", () => {
process.env.MEM0_TELEMETRY = "false";
expect(isTelemetryEnabled()).toBe(false);
expect(buildEvent("session_start", {}, KEY)).toBeNull();
});
test("captureEvent never throws (and sends nothing when opted out)", () => {
process.env.MEM0_TELEMETRY = "false";
expect(() => captureEvent("session_start", {}, KEY)).not.toThrow();
expect(() => captureEvent("session_start", {}, undefined)).not.toThrow();
});
});
@@ -1,104 +0,0 @@
/**
* Plugin telemetry for the Mem0 OpenCode plugin — anonymous usage tracking
* via PostHog.
*
* Emits the SAME event schema as the Mem0 editor plugin's telemetry.py
* (event names prefixed `plugin.`, `source: "plugin"`, `platform: "opencode"`,
* `distinct_id = sha256(apiKey)[:32]`) so OpenCode shows up as just another
* `platform` value in the shared plugin dashboard instead of a separate
* event namespace.
*
* Fire-and-forget: never throws, never blocks, failures are swallowed. Only
* fires when an API key is present (same as the editor plugin — anonymous
* installs without a key emit nothing). Disable with MEM0_TELEMETRY=false.
*
* Never sends: memory content, API keys, raw user/project IDs. Only sends:
* event type, platform, plugin version, anonymized hash of the API key.
*/
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
const REQUEST_TIMEOUT_MS = 2_000;
function _loadPluginVersion(): string {
// Source context: telemetry.ts sits next to package.json (./).
// Bundled context: dist/index.js sits one level below it (../).
for (const rel of ["./package.json", "../package.json"]) {
try {
const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), "utf-8"));
if (pkg?.name === "@mem0/opencode-plugin" && pkg.version) return pkg.version;
} catch {
/* try next candidate */
}
}
return "unknown";
}
const PLUGIN_VERSION = _loadPluginVersion();
export function isTelemetryEnabled(): boolean {
const val = process.env.MEM0_TELEMETRY;
if (val === undefined) return true;
const s = val.toLowerCase();
return s !== "false" && s !== "0" && s !== "no" && s !== "off";
}
function distinctId(apiKey: string): string {
// Matches telemetry.py `_distinct_id()` so the same user is one person in
// PostHog whether they use OpenCode or any other Mem0 editor plugin.
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
}
/**
* Build the PostHog event payload, or null when telemetry is disabled or no
* API key is available. Pure (aside from env/version reads) and exported for
* testing. System-controlled properties are applied last so a caller cannot
* override `source`/`platform`/etc.
*/
export function buildEvent(
eventType: string,
properties: Record<string, unknown>,
apiKey: string | undefined,
): Record<string, unknown> | null {
if (!isTelemetryEnabled() || !apiKey) return null;
return {
api_key: POSTHOG_API_KEY,
distinct_id: distinctId(apiKey),
event: `plugin.${eventType}`,
properties: {
...properties,
source: "plugin",
platform: "opencode",
plugin_version: PLUGIN_VERSION,
os: process.platform,
sample_rate: 1.0,
$process_person_profile: false,
$lib: "posthog-node",
},
};
}
/** Send a usage event, fire-and-forget. Never throws, never blocks. */
export function captureEvent(
eventType: string,
properties: Record<string, unknown>,
apiKey: string | undefined,
): void {
const payload = buildEvent(eventType, properties, apiKey);
if (!payload) return;
try {
void fetch(POSTHOG_HOST, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}).catch(() => {
/* fire-and-forget */
});
} catch {
/* never throw */
}
}
@@ -16,5 +16,5 @@
"emitDeclarationOnly": true
},
"include": ["**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
"exclude": ["node_modules", "dist"]
}
-15
View File
@@ -2,21 +2,6 @@
All notable changes to the Mem0 plugin will be documented in this file.
## 0.2.10 — Accurate per-editor telemetry attribution
### Fixed
- **Antigravity counted as Claude Code:** `detect_platform()` (`scripts/telemetry.py`) now checks `ANTIGRAVITY_PLUGIN_ROOT` before the `CLAUDE_PLUGIN_ROOT` branch. Antigravity sets both env vars for compatibility, so every Antigravity session was previously attributed to `claude-code`. Telemetry now reports `platform: "antigravity"`.
- **Codex fell back to the generic `plugin` bucket:** Codex installs standalone hooks with absolute paths via `install_codex_hooks.py`, so `PLUGIN_ROOT` is never set at runtime and platform auto-detection failed. Each command in `hooks/codex-hooks.json` now pins `MEM0_PLATFORM=codex` inline (Codex runs hook commands through a shell). Telemetry now reports `platform: "codex"`.
- **Cursor attribution depended on the host env:** Cursor's `*_cursor.sh` wrappers delegate to the shared hook scripts, whose platform detection relied on Cursor exporting `CURSOR_PLUGIN_ROOT` to the subprocess. All five Cursor wrappers now `export MEM0_PLATFORM=cursor` before delegating.
- **`plugin_version` was identical for every editor:** telemetry read `.claude-plugin/plugin.json` for all bash-hook editors, so Antigravity reported `0.2.10` instead of its real `0.1.2`. `_load_plugin_version()` now reads the manifest matching the detected platform, so each editor reports its own version.
### Added
- **`MEM0_PLATFORM` override in `detect_platform()`:** An explicit platform marker that wins over env-var auto-detection, letting each editor label its telemetry reliably. New tests in `tests/test_telemetry.py` cover the override, Antigravity attribution, and the Cursor/Codex platform-pinning contracts.
> Attribution fixes apply to telemetry emitted after users upgrade to this version; PostHog does not backfill past events.
## 0.2.9 — File-context injection, session summaries & activity timeline
### Added
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/block_memory_write.sh",
"command": "${PLUGIN_ROOT}/scripts/block_memory_write.sh",
"timeout": 3
}
]
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"command": "${PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
"timeout": 3
}
]
@@ -26,7 +26,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_file_read.sh",
"command": "${PLUGIN_ROOT}/scripts/on_file_read.sh",
"timeout": 5
}
]
@@ -38,7 +38,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_session_start.sh",
"command": "${PLUGIN_ROOT}/scripts/on_session_start.sh",
"statusMessage": "Loading mem0 context..."
}
]
@@ -49,7 +49,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"command": "${PLUGIN_ROOT}/scripts/on_user_prompt.sh",
"statusMessage": "Checking memory relevance...",
"timeout": 12
}
@@ -62,7 +62,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
"command": "${PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
"timeout": 3
}
]
@@ -72,7 +72,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_bash_output.sh",
"command": "${PLUGIN_ROOT}/scripts/on_bash_output.sh",
"timeout": 12
}
]
@@ -83,7 +83,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_stop.sh",
"command": "${PLUGIN_ROOT}/scripts/on_stop.sh",
"timeout": 30
}
]
@@ -94,7 +94,7 @@
"hooks": [
{
"type": "command",
"command": "MEM0_PLATFORM=codex ${PLUGIN_ROOT}/scripts/on_pre_compact.sh",
"command": "${PLUGIN_ROOT}/scripts/on_pre_compact.sh",
"statusMessage": "Preparing pre-compaction summary..."
}
]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "mem0",
"name": "mem0",
"version": "0.1.2",
"version": "0.1.1",
"description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"publisher": "mem0ai",
@@ -8,9 +8,6 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
# Run the shared tracker (output is ignored)
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
@@ -8,9 +8,6 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
@@ -8,9 +8,6 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
@@ -18,8 +18,6 @@ if [ -n "$AGENT_ID" ]; then
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so this hook's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
if [ -z "${MEM0_API_KEY:-}" ]; then
@@ -8,9 +8,6 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Pin platform so the shared script's telemetry is attributed to cursor.
export MEM0_PLATFORM=cursor
TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "")
if [ -z "$TEXT" ]; then
+5 -22
View File
@@ -26,27 +26,16 @@ import sys
import urllib.error
import urllib.request
# Each editor surface ships its own manifest with its own version line
# (Antigravity is on 0.1.x while Claude/Cursor/Codex are on 0.2.x), so we read
# the manifest matching the detected platform rather than a single shared one.
_PLATFORM_MANIFESTS = {
"antigravity": ("..", "plugin.json"),
"claude-code": ("..", ".claude-plugin", "plugin.json"),
"cursor": ("..", ".cursor-plugin", "plugin.json"),
"codex": ("..", ".codex-plugin", "plugin.json"),
}
_DEFAULT_MANIFEST = ("..", ".claude-plugin", "plugin.json")
def _load_plugin_version(platform_name: str = "") -> str:
parts = _PLATFORM_MANIFESTS.get(platform_name, _DEFAULT_MANIFEST)
def _load_plugin_version() -> str:
try:
plugin_json = os.path.join(os.path.dirname(__file__), *parts)
plugin_json = os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")
with open(plugin_json) as f:
return json.load(f).get("version", "unknown")
except (OSError, json.JSONDecodeError, KeyError):
return "unknown"
PLUGIN_VERSION = _load_plugin_version()
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
@@ -69,11 +58,6 @@ def _distinct_id() -> str:
def detect_platform() -> str:
explicit = os.environ.get("MEM0_PLATFORM")
if explicit:
return explicit
if os.environ.get("ANTIGRAVITY_PLUGIN_ROOT"):
return "antigravity"
if os.environ.get("PLUGIN_ROOT"):
return "codex"
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
@@ -91,7 +75,6 @@ def is_enabled() -> bool:
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
plat = detect_platform()
return {
"api_key": POSTHOG_API_KEY,
"distinct_id": _distinct_id(),
@@ -99,8 +82,8 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
"properties": {
**(properties or {}),
"source": "plugin",
"platform": plat,
"plugin_version": _load_plugin_version(plat),
"platform": detect_platform(),
"plugin_version": PLUGIN_VERSION,
"project_hash": _sha256(project_id),
"os": sys.platform,
"os_version": platform.version(),
@@ -89,7 +89,7 @@ def test_system_props_override_caller_props(monkeypatch):
# System props must win
assert props["source"] == "plugin"
assert props["platform"] == telemetry.detect_platform()
assert props["plugin_version"] == telemetry._load_plugin_version(telemetry.detect_platform())
assert props["plugin_version"] == telemetry.PLUGIN_VERSION
# Caller-only props still present
assert props["memory_count"] == 42
@@ -125,31 +125,25 @@ def test_hash_deterministic():
def test_platform_claude_code(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CLAUDECODE", "1")
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "claude-code"
def test_platform_cursor(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "cursor"
def test_platform_codex(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.delenv("ANTIGRAVITY_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
@@ -157,48 +151,6 @@ def test_platform_codex(monkeypatch):
assert telemetry.detect_platform() == "codex"
def test_platform_explicit_override(monkeypatch):
"""MEM0_PLATFORM wins over auto-detection so each editor can label
itself reliably even when host env vars are ambiguous or absent."""
import telemetry
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/path") # conflicting auto-signal
monkeypatch.setenv("MEM0_PLATFORM", "cursor")
assert telemetry.detect_platform() == "cursor"
def test_platform_antigravity(monkeypatch):
"""Antigravity sets CLAUDE_PLUGIN_ROOT for compatibility but must be
attributed to its own platform, not claude-code."""
import telemetry
monkeypatch.delenv("MEM0_PLATFORM", raising=False)
monkeypatch.setenv("ANTIGRAVITY_PLUGIN_ROOT", "/ext")
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", "/ext") # antigravity sets both
assert telemetry.detect_platform() == "antigravity"
def test_plugin_version_is_per_editor(monkeypatch):
"""Each editor reports the version from its OWN manifest. Antigravity is on
a 0.1.x line while Claude/Cursor/Codex are on 0.2.x, so they must not all
report the same shared version."""
import telemetry
plugin_dir = os.path.join(os.path.dirname(__file__), "..")
manifests = {
"antigravity": "plugin.json",
"claude-code": os.path.join(".claude-plugin", "plugin.json"),
"cursor": os.path.join(".cursor-plugin", "plugin.json"),
"codex": os.path.join(".codex-plugin", "plugin.json"),
}
for plat, rel in manifests.items():
monkeypatch.setenv("MEM0_PLATFORM", plat)
with open(os.path.join(plugin_dir, rel)) as f:
expected = json.load(f)["version"]
payload = telemetry.build_posthog_payload("plugin.test")
assert payload["properties"]["plugin_version"] == expected, f"{plat} should report {expected} from {rel}"
def test_send_fails_silently(monkeypatch):
import telemetry
@@ -223,39 +175,3 @@ def test_cli_no_args_exits_nonzero(monkeypatch):
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
assert telemetry.main() == 1
def test_cursor_wrappers_pin_platform():
"""Cursor wrappers delegate to the shared scripts, which auto-detect the
platform from host env vars. Since Cursor may not export CURSOR_PLUGIN_ROOT
to the subprocess, each wrapper must pin MEM0_PLATFORM=cursor so the
delegated telemetry is attributed to cursor, not the 'plugin' fallback."""
scripts_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
cursor_wrappers = [
"on_session_start_cursor.sh",
"on_user_prompt_cursor.sh",
"on_post_tool_use_cursor.sh",
"on_pre_compact_cursor.sh",
"on_stop_cursor.sh",
]
for name in cursor_wrappers:
with open(os.path.join(scripts_dir, name)) as f:
content = f.read()
assert "export MEM0_PLATFORM=cursor" in content, f"{name} must `export MEM0_PLATFORM=cursor` before delegating"
def test_codex_hooks_pin_platform():
"""Codex installs standalone hooks (absolute paths) via install_codex_hooks.py,
so PLUGIN_ROOT is not set at runtime and the platform falls back to 'plugin'.
Codex runs hook commands through a shell, so every command pins
MEM0_PLATFORM=codex inline for correct attribution."""
hooks_path = os.path.join(os.path.dirname(__file__), "..", "hooks", "codex-hooks.json")
with open(hooks_path) as f:
config = json.load(f)
commands = [
h["command"] for entries in config["hooks"].values() for entry in entries for h in entry.get("hooks", [])
]
assert commands, "expected at least one codex hook command"
for cmd in commands:
assert "MEM0_PLATFORM=codex" in cmd, f"codex hook command missing platform pin: {cmd}"
+2 -7
View File
@@ -25,11 +25,7 @@ import type {
AddOptions,
SearchOptions,
} from "./types.ts";
import {
createProvider,
customCategoryMapToList,
providerToBackend,
} from "./providers.ts";
import { createProvider, providerToBackend } from "./providers.ts";
import { mem0ConfigSchema } from "./config.ts";
import type { FileConfig } from "./config.ts";
import { createPublicArtifactsProvider } from "./public-artifacts.ts";
@@ -293,8 +289,7 @@ const memoryPlugin = definePluginEntry({
if (runId) opts.run_id = runId;
// Pass customInstructions and customCategories to control what Mem0 extracts
if (cfg.customInstructions) opts.custom_instructions = cfg.customInstructions;
const customCategories = customCategoryMapToList(cfg.customCategories);
if (customCategories) opts.custom_categories = customCategories;
if (cfg.customCategories) opts.custom_categories = cfg.customCategories;
return opts;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
"version": "1.0.13",
"version": "1.0.12",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
@@ -35,7 +35,7 @@
},
"dependencies": {
"@sinclair/typebox": "0.34.47",
"mem0ai": "3.0.7"
"mem0ai": "3.0.6"
},
"openclaw": {
"extensions": [
+144 -7
View File
@@ -20,8 +20,8 @@ importers:
specifier: 0.34.47
version: 0.34.47
mem0ai:
specifier: 3.0.7
version: 3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.21.0)(redis@5.12.1)(ws@8.21.0)
specifier: 3.0.6
version: 3.0.6(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.21.0)
devDependencies:
'@qdrant/js-client-rest':
specifier: ^1.18.0
@@ -398,12 +398,21 @@ packages:
resolution: {integrity: sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==}
engines: {node: '>=18.0.0', pnpm: '>=8'}
'@redis/bloom@1.2.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/bloom@5.12.1':
resolution: {integrity: sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==}
engines: {node: '>= 18.19.0'}
peerDependencies:
'@redis/client': ^5.12.1
'@redis/client@1.6.1':
resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==}
engines: {node: '>=14'}
'@redis/client@5.12.1':
resolution: {integrity: sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==}
engines: {node: '>= 18.19.0'}
@@ -416,18 +425,38 @@ packages:
'@opentelemetry/api':
optional: true
'@redis/graph@1.1.1':
resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/json@1.0.7':
resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/json@5.12.1':
resolution: {integrity: sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==}
engines: {node: '>= 18.19.0'}
peerDependencies:
'@redis/client': ^5.12.1
'@redis/search@1.2.0':
resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/search@5.12.1':
resolution: {integrity: sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==}
engines: {node: '>= 18.19.0'}
peerDependencies:
'@redis/client': ^5.12.1
'@redis/time-series@1.1.0':
resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==}
peerDependencies:
'@redis/client': ^1.0.0
'@redis/time-series@5.12.1':
resolution: {integrity: sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==}
engines: {node: '>= 18.19.0'}
@@ -469,36 +498,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
@@ -560,66 +595,79 @@ packages:
resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.61.1':
resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.61.1':
resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.61.1':
resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.61.1':
resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.61.1':
resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.61.1':
resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.61.1':
resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.61.1':
resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.61.1':
resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.61.1':
resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.61.1':
resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.61.1':
resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.61.1':
resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==}
@@ -868,6 +916,10 @@ packages:
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-writer@2.0.0:
resolution: {integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==}
engines: {node: '>=4'}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
@@ -1139,6 +1191,10 @@ packages:
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
engines: {node: '>=18'}
generic-pool@3.9.0:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1357,24 +1413,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -1444,8 +1504,8 @@ packages:
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
mem0ai@3.0.7:
resolution: {integrity: sha512-CUHzX7DyeKTHcI3aDsSqY9LXTD7GcFxf988796TuOa4yJgGuF2Xd2NROcBhVFRo3r9y8fVmbo3c5TF9jv1KlKw==}
mem0ai@3.0.6:
resolution: {integrity: sha512-X1KbCVE5351qpJ6X2KXFhQJiW+dqoHj1otA66DSdXtIEO1uXuIdyeTKeURdAH+wjXtIcFa08ylL2meA1pBk9Iw==}
engines: {node: '>=18'}
peerDependencies:
'@anthropic-ai/sdk': ^0.40.1
@@ -1636,6 +1696,9 @@ packages:
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
engines: {node: '>=8'}
packet-reader@1.0.0:
resolution: {integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1669,6 +1732,15 @@ packages:
resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==}
engines: {node: '>=10'}
pg@8.11.3:
resolution: {integrity: sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==}
engines: {node: '>= 8.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pg@8.21.0:
resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==}
engines: {node: '>= 16.0.0'}
@@ -1796,6 +1868,9 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
redis@4.7.1:
resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
redis@5.12.1:
resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==}
engines: {node: '>= 18.19.0'}
@@ -2150,6 +2225,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
@@ -2511,22 +2589,48 @@ snapshots:
'@qdrant/openapi-typescript-fetch@1.2.6': {}
'@redis/bloom@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/bloom@5.12.1(@redis/client@5.12.1)':
dependencies:
'@redis/client': 5.12.1
'@redis/client@1.6.1':
dependencies:
cluster-key-slot: 1.1.2
generic-pool: 3.9.0
yallist: 4.0.0
'@redis/client@5.12.1':
dependencies:
cluster-key-slot: 1.1.2
'@redis/graph@1.1.1(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/json@1.0.7(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/json@5.12.1(@redis/client@5.12.1)':
dependencies:
'@redis/client': 5.12.1
'@redis/search@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/search@5.12.1(@redis/client@5.12.1)':
dependencies:
'@redis/client': 5.12.1
'@redis/time-series@1.1.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
'@redis/time-series@5.12.1(@redis/client@5.12.1)':
dependencies:
'@redis/client': 5.12.1
@@ -2905,6 +3009,8 @@ snapshots:
buffer-equal-constant-time@1.0.1: {}
buffer-writer@2.0.0: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
@@ -3170,6 +3276,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
generic-pool@3.9.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3474,7 +3582,7 @@ snapshots:
crypt: 0.0.2
is-buffer: 1.1.6
mem0ai@3.0.7(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.21.0)(redis@5.12.1)(ws@8.21.0):
mem0ai@3.0.6(@anthropic-ai/sdk@0.40.1)(@azure/identity@4.13.1)(@azure/search-documents@12.2.0)(@cloudflare/workers-types@4.20260610.1)(@google/genai@1.52.0)(@langchain/core@1.1.48(openai@4.104.0(ws@8.21.0)(zod@3.25.76))(ws@8.21.0))(@mistralai/mistralai@1.15.1)(@qdrant/js-client-rest@1.18.0(typescript@5.9.3))(@supabase/supabase-js@2.108.1)(@types/jest@29.5.14)(@types/pg@8.11.0)(better-sqlite3@12.10.0)(cloudflare@4.5.0)(compromise@14.15.1)(groq-sdk@0.3.0)(natural@8.1.1)(ollama@0.5.18)(pg@8.11.3)(redis@4.7.1)(ws@8.21.0):
dependencies:
'@anthropic-ai/sdk': 0.40.1
'@azure/identity': 4.13.1
@@ -3495,8 +3603,8 @@ snapshots:
natural: 8.1.1
ollama: 0.5.18
openai: 4.104.0(ws@8.21.0)(zod@3.25.76)
pg: 8.21.0
redis: 5.12.1
pg: 8.11.3
redis: 4.7.1
uuid: 14.0.0
zod: 3.25.76
transitivePeerDependencies:
@@ -3675,6 +3783,8 @@ snapshots:
dependencies:
p-finally: 1.0.0
packet-reader@1.0.0: {}
pathe@2.0.3: {}
pg-cloudflare@1.4.0:
@@ -3686,6 +3796,10 @@ snapshots:
pg-numeric@1.0.2: {}
pg-pool@3.14.0(pg@8.11.3):
dependencies:
pg: 8.11.3
pg-pool@3.14.0(pg@8.21.0):
dependencies:
pg: 8.21.0
@@ -3710,6 +3824,18 @@ snapshots:
postgres-interval: 3.0.0
postgres-range: 1.1.4
pg@8.11.3:
dependencies:
buffer-writer: 2.0.0
packet-reader: 1.0.0
pg-connection-string: 2.13.0
pg-pool: 3.14.0(pg@8.11.3)
pg-protocol: 1.14.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
pg@8.21.0:
dependencies:
pg-connection-string: 2.13.0
@@ -3834,6 +3960,15 @@ snapshots:
readdirp@4.1.2: {}
redis@4.7.1:
dependencies:
'@redis/bloom': 1.2.0(@redis/client@1.6.1)
'@redis/client': 1.6.1
'@redis/graph': 1.1.1(@redis/client@1.6.1)
'@redis/json': 1.0.7(@redis/client@1.6.1)
'@redis/search': 1.2.0(@redis/client@1.6.1)
'@redis/time-series': 1.1.0(@redis/client@1.6.1)
redis@5.12.1:
dependencies:
'@redis/bloom': 5.12.1(@redis/client@5.12.1)
@@ -4146,6 +4281,8 @@ snapshots:
xtend@4.0.2: {}
yallist@4.0.0: {}
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
-10
View File
@@ -13,16 +13,6 @@ import type {
AddResult,
} from "./types.ts";
export function customCategoryMapToList(
categories?: Record<string, string>,
): Array<Record<string, string>> | undefined {
if (!categories || typeof categories !== "object") return undefined;
const items = Object.entries(categories).map(([name, description]) => ({
[name]: description,
}));
return items.length ? items : undefined;
}
// ============================================================================
// Result Normalizers
// ============================================================================
+1 -76
View File
@@ -6,11 +6,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
createProvider,
customCategoryMapToList,
providerToBackend,
} from "../providers.ts";
import { providerToBackend } from "../providers.ts";
// ---------------------------------------------------------------------------
// Mock provider factory
@@ -41,77 +37,6 @@ beforeEach(() => {
vi.resetAllMocks();
});
// ---------------------------------------------------------------------------
// customCategoryMapToList
// ---------------------------------------------------------------------------
describe("customCategoryMapToList", () => {
it("converts category maps to the Mem0 SDK list shape", () => {
expect(
customCategoryMapToList({
preference: "User preferences",
work: "Work context",
}),
).toEqual([
{ preference: "User preferences" },
{ work: "Work context" },
]);
});
it("returns undefined for empty or missing category maps", () => {
expect(customCategoryMapToList()).toBeUndefined();
expect(customCategoryMapToList({})).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// PlatformProvider
// ---------------------------------------------------------------------------
describe("PlatformProvider", () => {
it("passes custom_categories as a list to the Mem0 SDK", async () => {
let addOptions: Record<string, unknown> | undefined;
vi.doMock("mem0ai", () => ({
default: class MockMemoryClient {
async add(_messages: unknown, opts: Record<string, unknown>) {
addOptions = opts;
return { results: [] };
}
},
}));
const provider = createProvider(
{
mode: "platform",
apiKey: "test-key",
userId: "test-user",
autoCapture: true,
autoRecall: true,
customInstructions: "Store durable facts.",
customCategories: {},
searchThreshold: 0.1,
topK: 5,
},
{ resolvePath: (p: string) => p } as any,
);
await provider.add([{ role: "user", content: "Remember this" }], {
user_id: "test-user",
custom_categories: customCategoryMapToList({
preference: "User preferences",
}),
source: "OPENCLAW",
});
expect(addOptions?.customCategories).toEqual([
{ preference: "User preferences" },
]);
vi.doUnmock("mem0ai");
});
});
// ---------------------------------------------------------------------------
// search
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ export interface AddOptions {
user_id: string;
run_id?: string;
custom_instructions?: string;
custom_categories?: Array<Record<string, string>>;
custom_categories?: Record<string, string>;
source?: string;
// Agentic harness additions
infer?: boolean;
@@ -1,56 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
_getEventQueue,
_resetForTesting,
captureCommandEvent,
captureEvent,
captureToolEvent,
} from "./telemetry.ts";
const CTX = { apiKey: "m0-testkey123" };
function findEvent(name: string): Record<string, unknown> | undefined {
return _getEventQueue().find((e) => (e as Record<string, unknown>).event === name) as
| Record<string, unknown>
| undefined;
}
beforeEach(() => {
delete process.env.MEM0_TELEMETRY;
_resetForTesting();
});
afterEach(() => {
delete process.env.MEM0_TELEMETRY;
_resetForTesting();
});
describe("pi-agent telemetry", () => {
it.each(["add", "search", "update", "delete"])(
"captureToolEvent tracks the %s operation as pi.tool.mem0_memory",
(action) => {
captureToolEvent(action, { success: true }, CTX);
const ev = findEvent("pi.tool.mem0_memory");
expect(ev).toBeDefined();
const props = ev!.properties as Record<string, unknown>;
expect(props.action).toBe(action);
expect(props.success).toBe(true);
expect(props.source).toBe("PI_AGENT_PLUGIN");
expect(props.$process_person_profile).toBe(false);
},
);
it("captureCommandEvent emits a namespaced pi.command.* event", () => {
captureCommandEvent("mem0-search", { result_count: 3 }, CTX);
const ev = findEvent("pi.command.mem0-search");
expect(ev).toBeDefined();
expect((ev!.properties as Record<string, unknown>).result_count).toBe(3);
});
it("respects the MEM0_TELEMETRY opt-out", () => {
process.env.MEM0_TELEMETRY = "false";
captureEvent("pi.session.start", {}, CTX);
captureToolEvent("add", {}, CTX);
expect(_getEventQueue()).toHaveLength(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.0.8",
"version": "3.0.7",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+8 -230
View File
@@ -36,29 +36,10 @@ import {
SearchMemoryOptions,
DeleteAllMemoryOptions,
GetAllMemoryOptions,
UpdateProjectOptions,
} from "./memory.types";
import { parse_vision_messages } from "../utils/memory";
import { HistoryManager } from "../storage/base";
import { captureClientEvent } from "../utils/telemetry";
import {
detectScaleThresholdFromAddResult,
detectScaleThresholdFromTopK,
detectPerformanceSlowQuery,
detectTemporalUsageFromMetadata,
detectTemporalUsageFromSearch,
displayDecayUsageNotice,
displayFirstRunNotice,
displayPerformanceSlowQueryNotice,
displayScaleThresholdNotice,
displayTemporalUsageNotice,
getDecayFeatureErrorMessage,
getDecayUsageDeleteCountAfterSuccess,
getTemporalFeatureErrorMessage,
isDecayUsageDeleteEligible,
PerformanceSlowQueryTrigger,
ScaleThresholdTrigger,
} from "../utils/notices";
import { lemmatizeForBm25 } from "../utils/lemmatization";
import {
extractEntities,
@@ -513,73 +494,6 @@ export class Memory {
}
}
private async _displayFirstRunNotice(triggerFunction: string) {
try {
await this._getTelemetryId();
await displayFirstRunNotice(this, triggerFunction);
} catch {}
}
private async _displayDecayUsageNotice(trigger: {
triggerFunction: "delete" | "delete_all";
triggerSource: "delete_count" | "delete_all";
triggerReason: "repeated_deletes" | "bulk_delete";
deleteCount?: number;
deletedCount?: number;
}) {
try {
await this._getTelemetryId();
await displayDecayUsageNotice(this, trigger);
} catch {}
}
private async _displayTemporalUsageNotice(trigger: {
triggerFunction: "add" | "search";
triggerSource: "metadata" | "query" | "filter";
triggerReason:
| "date_like_metadata"
| "relative_phrase"
| "date_like_query"
| "date_range_filter";
}) {
try {
await this._getTelemetryId();
await displayTemporalUsageNotice(this, trigger);
} catch {}
}
private async _displayScaleThresholdNotice(trigger: ScaleThresholdTrigger) {
try {
await this._getTelemetryId();
await displayScaleThresholdNotice(this, trigger);
} catch {}
}
private async _displayPerformanceSlowQueryNotice(
trigger: PerformanceSlowQueryTrigger,
) {
try {
await this._getTelemetryId();
await displayPerformanceSlowQueryNotice(this, trigger);
} catch {}
}
private async _getNoticeTelemetryId() {
try {
if (
!this.telemetryId ||
this.telemetryId === "anonymous" ||
this.telemetryId === "anonymous-supabase"
) {
this.telemetryId = (await getOrCreateMem0UserId()) || "anonymous";
}
return this.telemetryId;
} catch {
this.telemetryId = "anonymous";
return this.telemetryId;
}
}
static fromConfig(configDict: Record<string, any>): Memory {
try {
const config = MemoryConfigSchema.parse(configDict);
@@ -590,29 +504,10 @@ export class Memory {
}
}
async updateProject(options: UpdateProjectOptions = {}): Promise<never> {
if (options?.decay === true) {
await this._getNoticeTelemetryId();
throw new Error(await getDecayFeatureErrorMessage(this));
}
throw new Error("Project updates are not supported by the OSS Memory SDK.");
}
async add(
messages: string | Message[],
config: AddMemoryOptions,
): Promise<SearchResult> {
if (config?.timestamp !== undefined) {
await this._getNoticeTelemetryId();
throw new Error(
await getTemporalFeatureErrorMessage(this, {
triggerFunction: "add",
triggerParameter: "timestamp",
}),
);
}
// Validate messages input
if (messages === undefined || messages === null) {
throw new Error(
@@ -620,10 +515,6 @@ export class Memory {
);
}
const temporalUsageNotice = detectTemporalUsageFromMetadata(
config?.metadata,
);
await this._ensureInitialized();
await this._captureEvent("add", {
message_count: Array.isArray(messages) ? messages.length : 1,
@@ -663,27 +554,6 @@ export class Memory {
infer,
);
if (temporalUsageNotice) {
await this._displayTemporalUsageNotice({
triggerFunction: "add",
triggerSource: temporalUsageNotice.triggerSource,
triggerReason: temporalUsageNotice.triggerReason,
});
} else {
const scaleThresholdNotice = await detectScaleThresholdFromAddResult(
this,
vectorStoreResult,
);
if (scaleThresholdNotice) {
await this._displayScaleThresholdNotice({
triggerFunction: "add",
...scaleThresholdNotice,
});
} else {
await this._displayFirstRunNotice("add");
}
}
return {
results: vectorStoreResult,
};
@@ -1126,10 +996,7 @@ export class Memory {
async get(memoryId: string): Promise<MemoryItem | null> {
await this._ensureInitialized();
const memory = await this.vectorStore.get(memoryId);
if (!memory) {
await this._displayFirstRunNotice("get");
return null;
}
if (!memory) return null;
const filters = {
...(memory.payload.user_id && { user_id: memory.payload.user_id }),
@@ -1164,30 +1031,13 @@ export class Memory {
}
}
const result = { ...memoryItem, ...filters };
await this._displayFirstRunNotice("get");
return result;
return { ...memoryItem, ...filters };
}
async search(
query: string,
config: SearchMemoryOptions,
): Promise<SearchResult> {
if (config?.referenceDate !== undefined) {
await this._getNoticeTelemetryId();
throw new Error(
await getTemporalFeatureErrorMessage(this, {
triggerFunction: "search",
triggerParameter: "referenceDate",
}),
);
}
const temporalUsageNotice = detectTemporalUsageFromSearch(
query,
config?.filters,
);
// Reject top-level entity params - must use filters instead
rejectTopLevelEntityParams(config as Record<string, any>, "search");
@@ -1255,8 +1105,6 @@ export class Memory {
);
}
const searchStartMs = Date.now();
// Step 1: Preprocess query
const queryLemmatized = lemmatizeForBm25(query);
const queryEntities = extractEntities(query);
@@ -1432,41 +1280,9 @@ export class Memory {
};
});
const result = {
return {
results,
};
const searchElapsedMs = Date.now() - searchStartMs;
if (temporalUsageNotice) {
await this._displayTemporalUsageNotice({
triggerFunction: "search",
triggerSource: temporalUsageNotice.triggerSource,
triggerReason: temporalUsageNotice.triggerReason,
});
} else {
const scaleThresholdNotice = detectScaleThresholdFromTopK(topK);
if (scaleThresholdNotice) {
await this._displayScaleThresholdNotice({
triggerFunction: "search",
...scaleThresholdNotice,
});
} else {
const performanceSlowQueryNotice = detectPerformanceSlowQuery(
searchElapsedMs,
topK,
results.length,
);
if (performanceSlowQueryNotice) {
await this._displayPerformanceSlowQueryNotice({
triggerFunction: "search",
triggerReason: "slow_query",
...performanceSlowQueryNotice,
});
} else {
await this._displayFirstRunNotice("search");
}
}
}
return result;
}
async update(memoryId: string, data: string): Promise<{ message: string }> {
@@ -1474,28 +1290,14 @@ export class Memory {
await this._captureEvent("update", { memory_id: memoryId });
const embedding = await this.embedder.embed(data);
await this.updateMemory(memoryId, data, { [data]: embedding });
const result = { message: "Memory updated successfully!" };
await this._displayFirstRunNotice("update");
return result;
return { message: "Memory updated successfully!" };
}
async delete(memoryId: string): Promise<{ message: string }> {
await this._ensureInitialized();
await this._captureEvent("delete", { memory_id: memoryId });
await this.deleteMemory(memoryId);
const result = { message: "Memory deleted successfully!" };
const deleteCount = getDecayUsageDeleteCountAfterSuccess();
if (isDecayUsageDeleteEligible(deleteCount)) {
await this._displayDecayUsageNotice({
triggerFunction: "delete",
triggerSource: "delete_count",
triggerReason: "repeated_deletes",
deleteCount,
});
} else {
await this._displayFirstRunNotice("delete");
}
return result;
return { message: "Memory deleted successfully!" };
}
async deleteAll(
@@ -1526,25 +1328,12 @@ export class Memory {
await this.deleteMemory(memory.id);
}
const result = { message: "Memories deleted successfully!" };
if (memories.length > 0) {
await this._displayDecayUsageNotice({
triggerFunction: "delete_all",
triggerSource: "delete_all",
triggerReason: "bulk_delete",
deletedCount: memories.length,
});
} else {
await this._displayFirstRunNotice("delete_all");
}
return result;
return { message: "Memories deleted successfully!" };
}
async history(memoryId: string): Promise<any[]> {
await this._ensureInitialized();
const result = await this.db.getHistory(memoryId);
await this._displayFirstRunNotice("history");
return result;
return this.db.getHistory(memoryId);
}
async reset(): Promise<void> {
@@ -1596,7 +1385,6 @@ export class Memory {
console.error(this._initError);
});
await this._initPromise;
await this._displayFirstRunNotice("reset");
}
async getAll(config: GetAllMemoryOptions): Promise<SearchResult> {
@@ -1664,17 +1452,7 @@ export class Memory {
...(mem.payload.run_id && { run_id: mem.payload.run_id }),
}));
const result = { results };
const scaleThresholdNotice = detectScaleThresholdFromTopK(topK);
if (scaleThresholdNotice) {
await this._displayScaleThresholdNotice({
triggerFunction: "get_all",
...scaleThresholdNotice,
});
} else {
await this._displayFirstRunNotice("get_all");
}
return result;
return { results };
}
private async createMemory(
@@ -11,7 +11,6 @@ export interface AddMemoryOptions extends Entity {
metadata?: Record<string, any>;
filters?: SearchFilters;
infer?: boolean;
timestamp?: number | string | Date | null;
}
export interface SearchMemoryOptions {
@@ -19,7 +18,6 @@ export interface SearchMemoryOptions {
filters?: SearchFilters;
threshold?: number;
explain?: boolean;
referenceDate?: number | string | Date | null;
}
export interface GetAllMemoryOptions {
@@ -28,8 +26,3 @@ export interface GetAllMemoryOptions {
}
export interface DeleteAllMemoryOptions extends Entity {}
export interface UpdateProjectOptions {
decay?: boolean;
[key: string]: any;
}
File diff suppressed because it is too large Load Diff
+6 -48
View File
@@ -13,12 +13,10 @@ let version =
// Safely check for process.env in different environments
let MEM0_TELEMETRY = true;
try {
MEM0_TELEMETRY =
process?.env?.MEM0_TELEMETRY?.toLowerCase() === "false" ? false : true;
MEM0_TELEMETRY = process?.env?.MEM0_TELEMETRY === "false" ? false : true;
} catch (error) {}
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
const NOTICE_EVENT_NAME = "mem0.notice_displayed";
// Default sampling rate for hot-path OSS events. Lifecycle events always fire at 100%.
// Override via MEM0_TELEMETRY_SAMPLE_RATE env var. Mirrors mem0/memory/telemetry.py.
@@ -37,11 +35,7 @@ const MEM0_TELEMETRY_SAMPLE_RATE: number = ((): number => {
})();
// Events that bypass sampling. Keep in sync with _captureEvent call sites in memory/index.ts.
const ALWAYS_SEND_EVENTS: ReadonlySet<string> = new Set([
"init",
"reset",
"notice_displayed",
]);
const LIFECYCLE_EVENTS: ReadonlySet<string> = new Set(["init", "reset"]);
class UnifiedTelemetry implements TelemetryClient {
private apiKey: string;
@@ -97,10 +91,6 @@ class UnifiedTelemetry implements TelemetryClient {
const telemetry = new UnifiedTelemetry(POSTHOG_API_KEY, POSTHOG_HOST);
function isTelemetryEnabled(): boolean {
return MEM0_TELEMETRY;
}
async function captureClientEvent(
eventName: string,
instance: TelemetryInstance,
@@ -112,8 +102,8 @@ async function captureClientEvent(
}
// >= so that rate=0 drops everything and rate=1 keeps everything (Math.random() ∈ [0, 1)).
const alwaysSend = ALWAYS_SEND_EVENTS.has(eventName);
if (!alwaysSend && Math.random() >= MEM0_TELEMETRY_SAMPLE_RATE) {
const isLifecycle = LIFECYCLE_EVENTS.has(eventName);
if (!isLifecycle && Math.random() >= MEM0_TELEMETRY_SAMPLE_RATE) {
return;
}
@@ -126,7 +116,7 @@ async function captureClientEvent(
client_source: "nodejs",
...additionalData,
// sample_rate set AFTER the spread so callers can never override it
sample_rate: alwaysSend ? 1.0 : MEM0_TELEMETRY_SAMPLE_RATE,
sample_rate: isLifecycle ? 1.0 : MEM0_TELEMETRY_SAMPLE_RATE,
};
await telemetry.captureEvent(
@@ -136,36 +126,4 @@ async function captureClientEvent(
);
}
async function captureNoticeEvent(
instance: TelemetryInstance,
properties: Record<string, any> = {},
) {
if (!instance.telemetryId) return;
const eventData: TelemetryEventData = {
function: `${instance.constructor.name}`,
method: "notice_displayed",
api_host: instance.host,
timestamp: new Date().toISOString(),
client_version: version,
client_source: "nodejs",
...properties,
sample_rate: 1.0,
};
await telemetry.captureEvent(
instance.telemetryId,
NOTICE_EVENT_NAME,
eventData,
);
}
export {
POSTHOG_API_KEY,
POSTHOG_HOST,
NOTICE_EVENT_NAME,
telemetry,
captureClientEvent,
captureNoticeEvent,
isTelemetryEnabled,
};
export { telemetry, captureClientEvent };
@@ -418,7 +418,6 @@ describe("Memory – LM Studio end-to-end flow", () => {
}));
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
isTelemetryEnabled: jest.fn(() => false),
}));
MemoryClass = require("../src/memory").Memory;
@@ -290,7 +290,6 @@ describe("Memory – auto-initialization", () => {
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
isTelemetryEnabled: jest.fn(() => false),
}));
MemoryClass = require("../src/memory").Memory;
@@ -1,387 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn(),
})),
}));
const DECAY_COPY =
'Memory decay requires Mem0 Platform. Get a free API key at https://app.mem0.ai?utm_source=oss_sdk&utm_medium=in_product&utm_campaign=decay_stub&utm_content=node_error and use `import MemoryClient from "mem0ai"`.';
const PLAIN_DECAY_ERROR =
"The decay parameter is not supported by the OSS Memory SDK.";
const PROJECT_UPDATE_ERROR =
"Project updates are not supported by the OSS Memory SDK.";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-decay-feature-"));
}
function decayPayload(overrides: Record<string, any> = {}) {
return {
notices: {
decay_stub: {
enabled: true,
notice_type: "error",
copy: DECAY_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(decayPayload())
: options.payload,
},
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-decay-feature-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
describe("Node OSS decay feature error notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("raises CTA copy for displayed and emits displayed=true", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
DECAY_COPY,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
notice_type: "error",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: DECAY_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: "update_project",
trigger_parameter: "decay",
sample_rate: 1,
}),
);
});
it("raises CTA copy for holdout and emits displayed=true", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
DECAY_COPY,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
variant: "holdout",
displayed: true,
trigger_function: "update_project",
trigger_parameter: "decay",
}),
);
expect(notices[0].properties.bypass_reason).toBeUndefined();
});
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: DECAY_COPY,
}),
);
});
it("uses plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: JSON.stringify(decayPayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
displayed: false,
bypass_reason: "payload_disabled",
disabled_reason: "payload_disabled",
notice_config_found: true,
}),
);
});
it.each([
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(decayPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("uses plain error for %s", async (_label, payload, bypassReason) => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
displayed: false,
bypass_reason: bypassReason,
}),
);
});
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "False";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
expect(fetchMock).not.toHaveBeenCalled();
expect(noticeEvents(calls)).toHaveLength(0);
});
it.each([
["empty options", {}],
["decay false", { decay: false }],
["non-decay options", { customInstructions: "Updated" }],
])("does not emit notice telemetry for %s", async (_label, options) => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject(options)).rejects.toThrow(
PROJECT_UPDATE_ERROR,
);
expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("does not trigger first-run notice or update_project telemetry", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
DECAY_COPY,
);
const eventNames = calls.map((call) => call.event);
expect(eventNames).toContain("mem0.notice_displayed");
expect(eventNames).not.toContain("mem0.update_project");
expect(
noticeEvents(calls).some(
(call) => call.properties.notice_id === "first_run",
),
).toBe(false);
});
});
@@ -1,526 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn(),
})),
}));
const DECAY_USAGE_COPY =
"Tip: Python fallback copy with memory.project.update(decay=True).";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-decay-usage-"));
}
function configPath(): string {
return path.join(process.env.MEM0_DIR as string, "config.json");
}
function readConfig(): Record<string, any> {
const file = configPath();
if (!fs.existsSync(file)) return {};
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeConfig(config: Record<string, any>) {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(config, null, 4));
}
function consumeFirstRun() {
writeConfig({
user_id: "node-decay-usage-test-user",
notice_state: {
first_run: {
consumed: true,
trigger_function: "test_setup",
variant: "test",
},
},
});
}
function decayUsagePayload(overrides: Record<string, any> = {}) {
return {
notices: {
decay_usage: {
enabled: true,
notice_type: "log_line",
copy: DECAY_USAGE_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(decayUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-decay-usage-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
async function addMemories(memory: any, userId: string, count: number) {
const ids: string[] = [];
for (let index = 0; index < count; index++) {
const result = await memory.add(`Decay usage memory ${index}`, {
userId,
infer: false,
});
ids.push(result.results[0].id);
}
return ids;
}
describe("Node OSS decay usage notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
let stderrSpy: jest.SpyInstance;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
stderrSpy = jest
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("does not evaluate or write decay state before the 5th successful delete", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
const ids = await addMemories(memory, "decay-delete-user", 4);
for (const id of ids) {
await memory.delete(id);
}
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
expect(stderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining(DECAY_USAGE_COPY),
);
});
it("evaluates on the 5th successful delete and records delete_count", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
const ids = await addMemories(memory, "decay-delete-user", 5);
for (const id of ids) {
await memory.delete(id);
}
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
notice_type: "log_line",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: DECAY_USAGE_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: "delete",
trigger_source: "delete_count",
trigger_reason: "repeated_deletes",
delete_count: 5,
sample_rate: 1,
}),
);
expect(stderrSpy.mock.calls.flat().join("")).toContain(DECAY_USAGE_COPY);
expect(readConfig().notice_state.decay_usage.events).toHaveLength(1);
});
it("does not count or emit when delete fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
jest
.spyOn(memory as any, "deleteMemory")
.mockRejectedValue(new Error("delete failed"));
await expect(memory.delete("memory-id")).rejects.toThrow("delete failed");
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("evaluates deleteAll after deleting at least one memory", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-delete-all-user", 3);
await memory.deleteAll({ userId: "decay-delete-all-user" });
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
variant: "displayed",
displayed: true,
payload: DECAY_USAGE_COPY,
trigger_function: "delete_all",
trigger_source: "delete_all",
trigger_reason: "bulk_delete",
deleted_count: 3,
}),
);
});
it("does not evaluate deleteAll when no memories are deleted", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.deleteAll({ userId: "empty-delete-all-user" });
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("does not evaluate when deleteAll fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.deleteAll({} as any)).rejects.toThrow(
"At least one filter is required",
);
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("is silent for holdout and emits displayed=false", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-holdout-user", 3);
await memory.deleteAll({ userId: "decay-holdout-user" });
expect(stderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining(DECAY_USAGE_COPY),
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
variant: "holdout",
displayed: false,
bypass_reason: "holdout",
trigger_function: "delete_all",
}),
);
});
it.each([
[
"disabled payload",
JSON.stringify(decayUsagePayload({ enabled: false })),
"payload_disabled",
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
undefined,
],
[
"missing copy",
JSON.stringify({
notices: {
decay_usage: { enabled: true, notice_type: "log_line" },
},
}),
"missing_copy",
undefined,
],
["malformed payload", "{not-json", "missing_notice_config", undefined],
])(
"stays silent and emits safe bypass for %s",
async (_label, payload, bypassReason, disabledReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-bypass-user", 3);
await memory.deleteAll({ userId: "decay-bypass-user" });
expect(stderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining(DECAY_USAGE_COPY),
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_usage",
displayed: false,
bypass_reason: bypassReason,
...(disabledReason && { disabled_reason: disabledReason }),
}),
);
},
);
it("does not emit or consume cap when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-flag-disabled-user", 3);
await memory.deleteAll({ userId: "decay-flag-disabled-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("does not emit or consume cap when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-posthog-failure-user", 3);
await memory.deleteAll({ userId: "decay-posthog-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "False";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-telemetry-off-user", 3);
await memory.deleteAll({ userId: "decay-telemetry-off-user" });
expect(fetchMock).not.toHaveBeenCalled();
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
});
it("blocks the 11th evaluated opportunity before flag evaluation", async () => {
consumeFirstRun();
const now = new Date();
writeConfig({
...readConfig(),
notice_state: {
...readConfig().notice_state,
first_run: readConfig().notice_state.first_run,
decay_usage: {
events: Array.from({ length: 10 }, (_, index) => ({
evaluated_at: new Date(now.getTime() - index * 1000).toISOString(),
variant: "displayed",
})),
},
},
});
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-cap-user", 1);
await memory.deleteAll({ userId: "decay-cap-user" });
expect(flagRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
});
it("does not consume first-run on a qualifying decay usage call", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify({
notices: {
first_run: {
enabled: true,
notice_type: "log_line",
copy: "First-run should not appear",
},
decay_usage: {
enabled: true,
notice_type: "log_line",
copy: DECAY_USAGE_COPY,
},
},
}),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addMemories(memory, "decay-priority-user", 3);
const config = readConfig();
delete config.notice_state.first_run;
writeConfig(config);
await memory.deleteAll({ userId: "decay-priority-user" });
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties.notice_id).toBe("decay_usage");
expect(readConfig().notice_state.first_run).toBeUndefined();
expect(readConfig().notice_state.decay_usage.events).toHaveLength(1);
});
});
@@ -1,409 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn().mockResolvedValue(
JSON.stringify({
memory: [{ id: "0", text: "stored fact", attributed_to: "user" }],
}),
),
})),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
const FIRST_RUN_COPY = "First-run CTA from PostHog";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-first-run-"));
}
function firstRunPayload(overrides: Record<string, any> = {}) {
return {
notices: {
first_run: {
enabled: true,
notice_type: "log_line",
copy: FIRST_RUN_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: Record<string, any>;
failFlags?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: true,
variant: options.variant ?? "displayed",
metadata: {
payload: JSON.stringify(options.payload ?? firstRunPayload()),
},
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-first-run-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
describe("Node OSS first-run notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
let stderrSpy: jest.SpyInstance;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
stderrSpy = jest
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("shows the displayed first-run copy once after a successful public call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Direct storage content", {
userId: "first-run-user",
infer: false,
});
await memory.add("Second direct storage content", {
userId: "first-run-user",
infer: false,
});
const stderrOutput = stderrSpy.mock.calls.flat().join("");
expect(stderrOutput.match(new RegExp(FIRST_RUN_COPY, "g"))).toHaveLength(1);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
notice_type: "log_line",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: FIRST_RUN_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: "add",
sample_rate: 1,
}),
);
const { __noticeTestHooks } = await import("../src/utils/notices");
const config = __noticeTestHooks.loadMem0Config();
expect(config.notice_state.first_run).toEqual(
expect.objectContaining({
consumed: true,
trigger_function: "add",
variant: "displayed",
}),
);
});
it("treats missing enabled as enabled, matching Python payload semantics", async () => {
const payload = firstRunPayload();
delete (payload.notices.first_run as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Missing enabled payload content", {
userId: "first-run-missing-enabled",
infer: false,
});
const stderrOutput = stderrSpy.mock.calls.flat().join("");
expect(stderrOutput).toContain(FIRST_RUN_COPY);
expect(noticeEvents(calls)[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
displayed: true,
payload: FIRST_RUN_COPY,
}),
);
});
it("is silent for holdout but still emits and consumes the opportunity", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Holdout content", {
userId: "first-run-holdout",
infer: false,
});
expect(stderrSpy).not.toHaveBeenCalledWith(
expect.stringContaining(FIRST_RUN_COPY),
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
variant: "holdout",
displayed: false,
bypass_reason: "holdout",
notice_config_found: true,
}),
);
});
it("is silent for disabled payloads and records a safe bypass", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: firstRunPayload({ enabled: false }),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Disabled payload content", {
userId: "first-run-disabled",
infer: false,
});
const stderrOutput = stderrSpy.mock.calls.flat().join("");
expect(stderrOutput).not.toContain(FIRST_RUN_COPY);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
displayed: false,
bypass_reason: "payload_disabled",
disabled_reason: "payload_disabled",
notice_config_found: true,
}),
);
});
it("lets the payload disabled kill switch take precedence over holdout", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: firstRunPayload({ enabled: false }),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Disabled holdout payload content", {
userId: "first-run-disabled-holdout",
infer: false,
});
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
variant: "holdout",
displayed: false,
bypass_reason: "payload_disabled",
disabled_reason: "payload_disabled",
notice_config_found: true,
}),
);
});
it("does not evaluate flags, emit, or write state when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "false";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Telemetry off content", {
userId: "first-run-off",
infer: false,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(noticeEvents(calls)).toHaveLength(0);
const { __noticeTestHooks } = await import("../src/utils/notices");
expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined();
});
it("does not export internal notice test hooks from the public OSS entrypoint", async () => {
const publicOssEntry = await import("../src");
expect(publicOssEntry).not.toHaveProperty("__noticeTestHooks");
});
it("treats uppercase False as telemetry off", async () => {
process.env.MEM0_TELEMETRY = "False";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Uppercase telemetry off content", {
userId: "first-run-off-uppercase",
infer: false,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(noticeEvents(calls)).toHaveLength(0);
const { __noticeTestHooks } = await import("../src/utils/notices");
expect(__noticeTestHooks.loadMem0Config().notice_state).toBeUndefined();
});
it("does not run after a failed public call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.add("Missing owner", {} as any)).rejects.toThrow(
"One of the filters: userId, agentId or runId is required!",
);
expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("does not break the successful operation when flag evaluation fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
const result = await memory.add("Flag failure content", {
userId: "first-run-failure",
infer: false,
});
expect(result.results).toHaveLength(1);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("can trigger from get() when get is the first successful public call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.get("missing-memory-id")).resolves.toBeNull();
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties.trigger_function).toBe("get");
});
});
@@ -1,227 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import type { TelemetryInstance } from "../src/utils/telemetry.types";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-notices-"));
}
function makeInstance(
overrides: Partial<TelemetryInstance> = {},
): TelemetryInstance {
return {
telemetryId: "notice-test-user",
constructor: { name: "Memory" },
host: "https://test.example.com",
...overrides,
};
}
describe("Node OSS notice foundation", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("writes notice state into isolated MEM0_DIR config and preserves user_id", async () => {
const notices = await import("../src/utils/notices");
const configPath = notices.getMem0ConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ user_id: "existing-user" }));
expect(
notices.recordNoticeOpportunity("foundation_notice", {
variant: "displayed",
}),
).toBe(true);
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(config.user_id).toBe("existing-user");
expect(config.notice_state.foundation_notice.events).toHaveLength(1);
});
it("writes config through a temp-file path and leaves no temp file behind", async () => {
const notices = await import("../src/utils/notices");
expect(notices.writeMem0ConfigAtomic({ user_id: "atomic-user" })).toBe(
true,
);
const configDir = path.dirname(notices.getMem0ConfigPath());
const tempFiles = fs
.readdirSync(configDir)
.filter((name) => name.endsWith(".tmp"));
expect(tempFiles).toHaveLength(0);
expect(notices.loadMem0Config().user_id).toBe("atomic-user");
});
it("allows 10 evaluated opportunities in a rolling window and blocks the 11th", async () => {
const notices = await import("../src/utils/notices");
const now = new Date("2026-06-11T12:00:00.000Z");
let state: Record<string, any> = {};
for (let i = 0; i < 10; i++) {
const nextState = notices.appendNoticeCapEvent(
state,
{ variant: "displayed", index: i },
{ now: new Date(now.getTime() + i) },
);
expect(nextState).not.toBeNull();
state = nextState!;
}
expect(notices.hasNoticeCapRoom(state, { now })).toBe(false);
expect(
notices.appendNoticeCapEvent(state, { variant: "displayed" }, { now }),
).toBeNull();
});
it("drops old cap events outside the rolling window", async () => {
const notices = await import("../src/utils/notices");
const oldEvent = {
evaluated_at: "2026-06-01T00:00:00.000Z",
variant: "displayed",
};
const now = new Date("2026-06-11T12:00:00.000Z");
expect(notices.hasNoticeCapRoom({ events: [oldEvent] }, { now })).toBe(
true,
);
const nextState = notices.appendNoticeCapEvent(
{ events: [oldEvent] },
{ variant: "displayed" },
{ now },
);
expect(nextState?.events).toHaveLength(1);
});
it("does not evaluate flags or write notice state when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "false";
jest.resetModules();
const fetchMock = jest.fn();
global.fetch = fetchMock as any;
const notices = await import("../src/utils/notices");
await expect(
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }),
).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(
notices.recordNoticeOpportunity("foundation_notice", {
variant: "displayed",
}),
).toBe(false);
expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false);
});
it("returns null when PostHog flag evaluation fails", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockRejectedValue(new Error("network down"));
await expect(
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }),
).resolves.toBeNull();
});
it("returns null when PostHog flag evaluation times out", async () => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn(
(_url: string | URL | Request, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
}),
);
await expect(
notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any,
timeoutMs: 1,
}),
).resolves.toBeNull();
});
it("parses displayed variant and JSON payload from PostHog flags response", async () => {
const notices = await import("../src/utils/notices");
const payload = {
notices: {
foundation_notice: {
enabled: true,
notice_type: "log_line",
copy: "Foundation notice",
},
},
};
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: true,
variant: "displayed",
metadata: { payload: JSON.stringify(payload) },
},
},
}),
});
const result = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
expect(result?.variant).toBe("displayed");
const parsed = notices.getNoticeConfigFromPayload(
result?.payload,
"foundation_notice",
);
expect(parsed.found).toBe(true);
expect(parsed.config?.copy).toBe("Foundation notice");
});
it("emits mem0.notice_displayed with sample_rate=1", async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
text: jest.fn().mockResolvedValue(""),
});
global.fetch = fetchMock as any;
const notices = await import("../src/utils/notices");
await notices.emitNoticeDisplayed(makeInstance(), {
notice_id: "foundation_notice",
notice_type: "log_line",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.event).toBe("mem0.notice_displayed");
expect(body.properties.sample_rate).toBe(1);
expect(body.properties.notice_id).toBe("foundation_notice");
});
});
@@ -1,492 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn(),
})),
}));
const PERFORMANCE_COPY =
"Mem0 Platform is optimized for this type of workload. Its retrieval benchmarked at ~0.8-1.09s p50 across LoCoMo, LongMemEval, and BEAM 1M/10M workloads; you can use it for free by getting an API key at: https://app.mem0.ai";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-performance-"));
}
function configPath(): string {
return path.join(process.env.MEM0_DIR as string, "config.json");
}
function readConfig(): Record<string, any> {
const file = configPath();
if (!fs.existsSync(file)) return {};
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeConfig(config: Record<string, any>) {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(config, null, 4));
}
function consumeFirstRun() {
writeConfig({
user_id: "node-performance-test-user",
notice_state: {
first_run: {
consumed: true,
trigger_function: "test_setup",
variant: "test",
},
},
});
}
function performancePayload(overrides: Record<string, any> = {}) {
return {
notices: {
performance_slow_query: {
enabled: true,
notice_type: "log_line",
copy: PERFORMANCE_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(performancePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function performanceEvents(calls: any[]) {
return noticeEvents(calls).filter(
(call) => call.properties.notice_id === "performance_slow_query",
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
function mockSearchElapsed(elapsedMs: number) {
let now = 1000;
jest.spyOn(Date, "now").mockImplementation(() => {
const current = now;
now += elapsedMs;
return current;
});
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-performance-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
async function addSeed(memory: any, userId = "performance-user") {
await memory.add("The user's favorite drink is green tea.", {
userId,
infer: false,
});
}
describe("Node OSS performance slow query notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
let stderrSpy: jest.SpyInstance;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
stderrSpy = jest
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("displays and emits safe fields after a slow successful search", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
mockSearchElapsed(2345);
const result = await memory.search("favorite drink private text", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(result.results.length).toBeGreaterThan(0);
expect(stderrSpy).toHaveBeenCalledWith(`${PERFORMANCE_COPY}\n`);
const event = performanceEvents(calls)[0];
expect(event.properties).toEqual(
expect.objectContaining({
notice_id: "performance_slow_query",
notice_type: "log_line",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: PERFORMANCE_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: "search",
trigger_reason: "slow_query",
elapsed_ms: 2345,
threshold_ms: 2000,
top_k: 3,
result_count: result.results.length,
sample_rate: 1,
}),
);
const serialized = JSON.stringify(event.properties);
expect(serialized).not.toContain("private text");
expect(serialized).not.toContain("performance-user");
});
it("is silent for holdout and emits displayed=false", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(stderrSpy).not.toHaveBeenCalled();
expect(performanceEvents(calls)[0].properties).toEqual(
expect.objectContaining({
notice_id: "performance_slow_query",
variant: "holdout",
displayed: false,
bypass_reason: "holdout",
}),
);
});
it.each([
[
"disabled payload",
JSON.stringify(performancePayload({ enabled: false })),
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(performancePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(stderrSpy).not.toHaveBeenCalled();
expect(performanceEvents(calls)[0].properties).toEqual(
expect.objectContaining({
displayed: false,
bypass_reason: bypassReason,
}),
);
});
it("does not evaluate fast searches", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(100);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not evaluate failed searches", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
mockSearchElapsed(2345);
await expect(
memory.search("private slow query", {
filters: {},
topK: 3,
}),
).rejects.toThrow("filters must contain");
expect(flagRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not consume cap or emit when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
});
it("does nothing when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "false";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(fetchMock).not.toHaveBeenCalled();
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined();
});
it("caps evaluated opportunities at 10 per rolling week", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
mockSearchElapsed(2345);
for (let index = 1; index <= 11; index++) {
await memory.search(`favorite drink ${index}`, {
filters: { user_id: "performance-user" },
topK: 3,
});
}
expect(performanceEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(
readConfig().notice_state.performance_slow_query.events,
).toHaveLength(10);
});
it("lets temporal usage and scale threshold take priority", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("what happened last week?", {
filters: { user_id: "performance-user" },
topK: 3,
});
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 50,
});
expect(performanceEvents(calls)).toHaveLength(0);
const noticeIds = noticeEvents(calls).map(
(call) => call.properties.notice_id,
);
expect(noticeIds).toEqual(["temporal_usage", "scale_threshold"]);
});
it("does not consume first-run on the same qualifying performance call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(1);
expect(readConfig().notice_state.first_run).toBeUndefined();
});
});
@@ -1,463 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn(),
})),
}));
const SCALE_TOP_K_COPY = "Scale top {top_k}";
const SCALE_MEMORY_COUNT_COPY = "Scale count {memory_count}";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-scale-"));
}
function configPath(): string {
return path.join(process.env.MEM0_DIR as string, "config.json");
}
function readConfig(): Record<string, any> {
const file = configPath();
if (!fs.existsSync(file)) return {};
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeConfig(config: Record<string, any>) {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(config, null, 4));
}
function consumeFirstRun(scaleState: Record<string, any> = {}) {
writeConfig({
user_id: "node-scale-test-user",
notice_state: {
first_run: {
consumed: true,
trigger_function: "test_setup",
variant: "test",
},
...(Object.keys(scaleState).length > 0 && {
scale_threshold: scaleState,
}),
},
});
}
function scalePayload(overrides: Record<string, any> = {}) {
return {
notices: {
scale_threshold: {
enabled: true,
notice_type: "log_line",
copies: {
top_k: SCALE_TOP_K_COPY,
memory_count: SCALE_MEMORY_COUNT_COPY,
},
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(scalePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function scaleEvents(calls: any[]) {
return noticeEvents(calls).filter(
(call) => call.properties.notice_id === "scale_threshold",
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-scale-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
async function addSeed(memory: any, userId = "scale-user") {
await memory.add("The user's favorite drink is green tea.", {
userId,
infer: false,
});
}
describe("Node OSS scale threshold notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
let stderrSpy: jest.SpyInstance;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
stderrSpy = jest
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("detects high topK after successful search", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
const result = await memory.search("favorite drink private text", {
filters: { user_id: "scale-user" },
topK: 50,
});
expect(result.results.length).toBeGreaterThan(0);
expect(stderrSpy).toHaveBeenCalledWith("Scale top 50\n");
const event = scaleEvents(calls)[0];
expect(event.properties).toMatchObject({
notice_id: "scale_threshold",
displayed: true,
trigger_function: "search",
trigger_source: "top_k",
trigger_reason: "high_top_k",
top_k: 50,
threshold: 50,
sample_rate: 1,
});
expect(JSON.stringify(event.properties)).not.toContain("private text");
});
it("detects high topK after successful getAll", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
const result = await memory.getAll({
filters: { user_id: "scale-user" },
topK: 50,
});
expect(result.results.length).toBeGreaterThan(0);
expect(stderrSpy).toHaveBeenCalledWith("Scale top 50\n");
const event = scaleEvents(calls)[0];
expect(event.properties).toMatchObject({
notice_id: "scale_threshold",
displayed: true,
trigger_function: "get_all",
trigger_source: "top_k",
trigger_reason: "high_top_k",
top_k: 50,
threshold: 50,
});
});
it("does not evaluate topK below the threshold", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
await memory.search("favorite drink", {
filters: { user_id: "scale-user" },
topK: 49,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.scale_threshold).toBeUndefined();
});
it("marks memory-count threshold evaluated before PostHog display", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
(memory as any).vectorStore.count = jest
.fn()
.mockResolvedValue({ count: 2000 });
await memory.add("Scale count threshold fixture.", {
userId: "scale-user",
infer: false,
});
expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1);
expect(
readConfig().notice_state.scale_threshold
.memory_count_threshold_evaluated,
).toBe(true);
expect(scaleEvents(calls)).toHaveLength(0);
});
it("does not count provider memories once threshold was evaluated", async () => {
consumeFirstRun({ memory_count_threshold_evaluated: true });
const { fetchMock } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
(memory as any).vectorStore.count = jest
.fn()
.mockResolvedValue({ count: 2000 });
await memory.add("Scale count already evaluated fixture.", {
userId: "scale-user",
infer: false,
});
expect((memory as any).vectorStore.count).not.toHaveBeenCalled();
expect(flagRequestCount(fetchMock)).toBe(0);
});
it("throttles under-threshold provider counts", async () => {
consumeFirstRun();
const { fetchMock } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
(memory as any).vectorStore.count = jest
.fn()
.mockResolvedValue({ count: 1999 });
await memory.add("Scale count under threshold one.", {
userId: "scale-user",
infer: false,
});
await memory.add("Scale count under threshold two.", {
userId: "scale-user",
infer: false,
});
expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1);
expect(flagRequestCount(fetchMock)).toBe(0);
});
it("records holdout and disabled variants silently", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "holdout",
payload: JSON.stringify(scalePayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
await memory.search("favorite drink", {
filters: { user_id: "scale-user" },
topK: 50,
});
expect(stderrSpy).not.toHaveBeenCalled();
const event = scaleEvents(calls)[0];
expect(event.properties).toMatchObject({
displayed: false,
bypass_reason: "payload_disabled",
disabled_reason: "payload_disabled",
variant: "holdout",
});
});
it("treats missing enabled as enabled for scale payloads", async () => {
consumeFirstRun();
const payload = scalePayload();
delete (payload.notices.scale_threshold as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(payload),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
await memory.search("favorite drink", {
filters: { user_id: "scale-user" },
topK: 50,
});
expect(stderrSpy.mock.calls.flat().join("")).toContain("Scale top 50");
expect(scaleEvents(calls)[0].properties).toMatchObject({
notice_id: "scale_threshold",
displayed: true,
payload: "Scale top 50",
});
});
it("caps evaluated scale opportunities at 10 per week", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSeed(memory);
stderrSpy.mockClear();
for (let index = 0; index < 11; index++) {
await memory.search(`favorite drink ${index}`, {
filters: { user_id: "scale-user" },
topK: 50,
});
}
expect(scaleEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10);
});
it("does not consume first-run on the same qualifying scale call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.getAll({
filters: { user_id: "scale-user" },
topK: 50,
});
expect(scaleEvents(calls)).toHaveLength(1);
expect(readConfig().notice_state?.first_run).toBeUndefined();
});
it("telemetry off skips flag evaluation, event emission, and state writes", async () => {
process.env.MEM0_TELEMETRY = "false";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.getAll({
filters: { user_id: "scale-user" },
topK: 50,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined();
});
});
@@ -1,516 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
const mockEmbed = jest.fn().mockResolvedValue(mockEmbedding);
const mockEmbedBatch = jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: mockEmbed,
embedBatch: mockEmbedBatch,
embeddingDims: 1536,
})),
}));
const mockGenerateResponse = jest.fn();
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: mockGenerateResponse,
})),
}));
const TEMPORAL_COPY =
"Temporal reasoning requires a Mem0 API key. Get one for free at https://app.mem0.ai";
const PLAIN_TIMESTAMP_ERROR =
"The timestamp parameter is not supported by the OSS Memory SDK.";
const PLAIN_REFERENCE_DATE_ERROR =
"The referenceDate parameter is not supported by the OSS Memory SDK.";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-temporal-feature-"));
}
function temporalPayload(overrides: Record<string, any> = {}) {
return {
notices: {
temporal_stub: {
enabled: true,
notice_type: "error",
copy: TEMPORAL_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(temporalPayload())
: options.payload,
},
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-temporal-feature-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
describe("Node OSS temporal feature error notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
mockEmbed.mockClear();
mockEmbedBatch.mockClear();
mockGenerateResponse.mockClear();
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it.each([
["add", "timestamp"],
["search", "referenceDate"],
])(
"raises CTA copy for displayed %s(%s) and emits displayed=true",
async (triggerFunction, triggerParameter) => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
if (triggerFunction === "add") {
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(TEMPORAL_COPY);
} else {
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
}),
).rejects.toThrow(TEMPORAL_COPY);
}
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
notice_type: "error",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: TEMPORAL_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: triggerFunction,
trigger_parameter: triggerParameter,
sample_rate: 1,
}),
);
},
);
it.each([
["add", "timestamp"],
["search", "referenceDate"],
])(
"raises CTA copy for holdout %s(%s) and emits displayed=true",
async (triggerFunction, triggerParameter) => {
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
if (triggerFunction === "add") {
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: null,
}),
).rejects.toThrow(TEMPORAL_COPY);
} else {
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: null,
}),
).rejects.toThrow(TEMPORAL_COPY);
}
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
variant: "holdout",
displayed: true,
trigger_function: triggerFunction,
trigger_parameter: triggerParameter,
}),
);
expect(notices[0].properties.bypass_reason).toBeUndefined();
},
);
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: TEMPORAL_COPY,
}),
);
});
it("treats missing enabled as enabled for feature-error payloads", async () => {
const payload = temporalPayload();
delete (payload.notices.temporal_stub as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(payload),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(TEMPORAL_COPY);
expect(noticeEvents(calls)[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
displayed: true,
payload: TEMPORAL_COPY,
}),
);
});
it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload: JSON.stringify(temporalPayload({ enabled: false })),
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
displayed: false,
bypass_reason: "payload_disabled",
disabled_reason: "payload_disabled",
notice_config_found: true,
trigger_function: "add",
trigger_parameter: "timestamp",
}),
);
});
it.each([
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])(
"uses referenceDate plain error for %s",
async (_label, payload, bypassReason) => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
}),
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
displayed: false,
bypass_reason: bypassReason,
trigger_function: "search",
trigger_parameter: "referenceDate",
}),
);
},
);
it("uses plain error and emits no event when the blunt flag is disabled", async () => {
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and emits no event when PostHog fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
}),
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR);
expect(noticeEvents(calls)).toHaveLength(0);
});
it("uses plain error and skips flag evaluation when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "False";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
expect(fetchMock).not.toHaveBeenCalled();
expect(noticeEvents(calls)).toHaveLength(0);
});
it("throws before add validation, normal telemetry, embeddings, and first-run", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
const addToVectorStoreSpy = jest.spyOn(memory as any, "addToVectorStore");
await expect(
memory.add(
undefined as any,
{
timestamp: 1778112000,
} as any,
),
).rejects.toThrow(TEMPORAL_COPY);
expect(mockEmbed).not.toHaveBeenCalled();
expect(addToVectorStoreSpy).not.toHaveBeenCalled();
expect(calls.map((call) => call.event)).not.toContain("mem0.add");
expect(
noticeEvents(calls).some(
(call) => call.properties.notice_id === "first_run",
),
).toBe(false);
});
it("throws before search validation, normal telemetry, embeddings, and first-run", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
userId: "invalid-top-level-user",
referenceDate: "2026-05-06",
} as any),
).rejects.toThrow(TEMPORAL_COPY);
expect(mockEmbed).not.toHaveBeenCalled();
expect(calls.map((call) => call.event)).not.toContain("mem0.search");
expect(
noticeEvents(calls).some(
(call) => call.properties.notice_id === "first_run",
),
).toBe(false);
});
it("leaves normal add/search calls unchanged when temporal options are omitted", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
const addResult = await memory.add("Normal add", {
userId: "normal-user",
infer: false,
});
expect(addResult.results).toHaveLength(1);
const searchResult = await memory.search("Normal add", {
filters: { user_id: "normal-user" },
topK: 3,
});
expect(searchResult.results.length).toBeGreaterThanOrEqual(0);
const eventNames = calls.map((call) => call.event);
expect(eventNames).toContain("mem0.add");
expect(eventNames).toContain("mem0.search");
expect(
noticeEvents(calls).some(
(call) => call.properties.notice_id === "temporal_stub",
),
).toBe(false);
});
});
@@ -1,594 +0,0 @@
/// <reference types="jest" />
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
jest.setTimeout(15000);
jest.mock("../src/embeddings/google", () => ({
GoogleEmbedder: jest.fn(),
}));
jest.mock("../src/llms/google", () => ({
GoogleLLM: jest.fn(),
}));
const mockEmbedding = new Array(1536).fill(0.1);
jest.mock("../src/embeddings/openai", () => ({
OpenAIEmbedder: jest.fn().mockImplementation(() => ({
embed: jest.fn().mockResolvedValue(mockEmbedding),
embedBatch: jest
.fn()
.mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => mockEmbedding)),
),
embeddingDims: 1536,
})),
}));
jest.mock("../src/llms/openai", () => ({
OpenAILLM: jest.fn().mockImplementation(() => ({
generateResponse: jest.fn(),
})),
}));
const TEMPORAL_USAGE_COPY =
"This looks like a time-aware memory workflow. Mem0 Platform has temporal reasoning built in. Use `timestamp` when adding memories and `reference_date` when searching.";
function makeTempMem0Dir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "mem0-node-temporal-usage-"));
}
function configPath(): string {
return path.join(process.env.MEM0_DIR as string, "config.json");
}
function readConfig(): Record<string, any> {
const file = configPath();
if (!fs.existsSync(file)) return {};
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function writeConfig(config: Record<string, any>) {
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
fs.writeFileSync(configPath(), JSON.stringify(config, null, 4));
}
function consumeFirstRun() {
writeConfig({
user_id: "node-temporal-usage-test-user",
notice_state: {
first_run: {
consumed: true,
trigger_function: "test_setup",
variant: "test",
},
},
});
}
function temporalUsagePayload(overrides: Record<string, any> = {}) {
return {
notices: {
temporal_usage: {
enabled: true,
notice_type: "log_line",
copy: TEMPORAL_USAGE_COPY,
...overrides,
},
},
};
}
function createFetchMock(options: {
variant?: string;
payload?: unknown;
failFlags?: boolean;
flagEnabled?: boolean;
}) {
const calls: any[] = [];
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url);
if (target.includes("/flags")) {
if (options.failFlags) {
throw new Error("flag evaluation failed");
}
const payload =
options.payload === undefined
? JSON.stringify(temporalUsagePayload())
: options.payload;
return {
ok: true,
json: jest.fn().mockResolvedValue({
flags: {
"mem0-oss-notices": {
key: "mem0-oss-notices",
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}),
};
}
if (target.includes("/i/v0/e/")) {
calls.push(JSON.parse(String(init?.body)));
return {
ok: true,
text: jest.fn().mockResolvedValue(""),
};
}
return {
ok: true,
json: jest.fn().mockResolvedValue({}),
text: jest.fn().mockResolvedValue(""),
};
});
return { fetchMock, calls };
}
function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed");
}
function temporalUsageEvents(calls: any[]) {
return noticeEvents(calls).filter(
(call) => call.properties.notice_id === "temporal_usage",
);
}
function flagRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
.length;
}
async function createMemory() {
const { Memory } = await import("../src/memory");
return new Memory({
version: "v1.1",
embedder: {
provider: "openai",
config: { apiKey: "test-key", model: "text-embedding-3-small" },
},
vectorStore: {
provider: "memory",
config: {
collectionName: `test-temporal-usage-${Date.now()}-${Math.random()}`,
dimension: 1536,
dbPath: ":memory:",
},
},
llm: {
provider: "openai",
config: { apiKey: "test-key", model: "gpt-5-mini" },
},
historyDbPath: ":memory:",
});
}
async function addSearchSeed(memory: any, userId = "temporal-user") {
await memory.add("The user's favorite drink is green tea.", {
userId,
infer: false,
});
}
describe("Node OSS temporal usage notice", () => {
let originalMem0Dir: string | undefined;
let originalTelemetry: string | undefined;
let originalSampleRate: string | undefined;
let originalFetch: typeof global.fetch;
let stderrSpy: jest.SpyInstance;
beforeEach(() => {
originalMem0Dir = process.env.MEM0_DIR;
originalTelemetry = process.env.MEM0_TELEMETRY;
originalSampleRate = process.env.MEM0_TELEMETRY_SAMPLE_RATE;
originalFetch = global.fetch;
process.env.MEM0_DIR = makeTempMem0Dir();
process.env.MEM0_TELEMETRY = "true";
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1";
stderrSpy = jest
.spyOn(process.stderr, "write")
.mockImplementation(() => true);
jest.resetModules();
});
afterEach(() => {
if (originalMem0Dir === undefined) delete process.env.MEM0_DIR;
else process.env.MEM0_DIR = originalMem0Dir;
if (originalTelemetry === undefined) delete process.env.MEM0_TELEMETRY;
else process.env.MEM0_TELEMETRY = originalTelemetry;
if (originalSampleRate === undefined) {
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
} else {
process.env.MEM0_TELEMETRY_SAMPLE_RATE = originalSampleRate;
}
global.fetch = originalFetch;
jest.restoreAllMocks();
jest.resetModules();
});
it("detects timestamp-like metadata after successful add", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
const result = await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(result.results).toHaveLength(1);
expect(stderrSpy).toHaveBeenCalledWith(`${TEMPORAL_USAGE_COPY}\n`);
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
notice_type: "log_line",
flag_key: "mem0-oss-notices",
variant: "displayed",
displayed: true,
payload: TEMPORAL_USAGE_COPY,
notice_config_found: true,
sync_type: "async",
trigger_function: "add",
trigger_source: "metadata",
trigger_reason: "date_like_metadata",
sample_rate: 1,
}),
);
expect(readConfig().notice_state.temporal_usage.events).toHaveLength(1);
});
it("emits query trigger fields after successful search", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSearchSeed(memory);
await memory.search("what happened last week?", {
filters: { user_id: "temporal-user" },
topK: 3,
});
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
displayed: true,
trigger_function: "search",
trigger_source: "query",
trigger_reason: "relative_phrase",
}),
);
});
it("detects temporal range filters after successful search", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSearchSeed(memory);
await memory.search("favorite drink", {
filters: {
user_id: "temporal-user",
AND: [{ created_at: { gte: "2025-04-01" } }],
},
topK: 3,
});
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
displayed: true,
trigger_function: "search",
trigger_source: "filter",
trigger_reason: "date_range_filter",
}),
);
});
it("does not evaluate for normal non-temporal add/search calls", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Normal memory", {
userId: "normal-user",
infer: false,
metadata: { category: "planning" },
});
await memory.search("favorite drink", {
filters: { user_id: "normal-user" },
topK: 3,
});
expect(flagRequestCount(fetchMock)).toBe(0);
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does not evaluate when add/search fail", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Invalid temporal metadata add", {
infer: false,
metadata: { event_date: "2025-04-09" },
} as any),
).rejects.toThrow("One of the filters");
await expect(
memory.search("what happened last week?", {
filters: {},
}),
).rejects.toThrow("filters must contain");
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("is silent for holdout and emits displayed=false", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "holdout" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(stderrSpy).not.toHaveBeenCalled();
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
variant: "holdout",
displayed: false,
bypass_reason: "holdout",
trigger_source: "metadata",
trigger_reason: "date_like_metadata",
}),
);
});
it.each([
[
"disabled payload",
JSON.stringify(temporalUsagePayload({ enabled: false })),
"payload_disabled",
],
[
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalUsagePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
payload,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(stderrSpy).not.toHaveBeenCalled();
const notices = temporalUsageEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_usage",
displayed: false,
bypass_reason: bypassReason,
}),
);
});
it("does not consume cap or emit when the blunt flag is disabled", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does not consume cap or emit when PostHog fails", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
});
it("does nothing when telemetry is off", async () => {
process.env.MEM0_TELEMETRY = "False";
jest.resetModules();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(fetchMock).not.toHaveBeenCalled();
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined();
});
it("caps evaluated opportunities at 10 per rolling week", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSearchSeed(memory);
for (let index = 1; index <= 11; index++) {
await memory.search(`what happened last week ${index}?`, {
filters: { user_id: "temporal-user" },
topK: 3,
});
}
expect(temporalUsageEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10);
expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10);
expect(stderrSpy).toHaveBeenCalledTimes(10);
});
it("does not consume first-run on the same qualifying temporal usage call", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(readConfig().notice_state.first_run).toBeUndefined();
await memory.add("Normal follow-up memory", {
userId: "temporal-user",
infer: false,
});
const notices = noticeEvents(calls);
expect(notices.map((call) => call.properties.notice_id)).toEqual([
"temporal_usage",
"first_run",
]);
expect(readConfig().notice_state.first_run.consumed).toBe(true);
});
it("does not include raw query, metadata, or filter values in telemetry props", async () => {
consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ variant: "displayed" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await addSearchSeed(memory);
await memory.search("private trip since 2025-04-09", {
filters: {
user_id: "temporal-user",
created_at: { gte: "2025-04-09" },
},
topK: 3,
});
const props = temporalUsageEvents(calls)[0].properties;
const serialized = JSON.stringify(props);
expect(serialized).not.toContain("private trip");
expect(serialized).not.toContain("2025-04-09");
expect(serialized).not.toContain("temporal-user");
});
it("detects expected query, metadata, and filter cases conservatively", async () => {
const { __noticeTestHooks } = await import("../src/utils/notices");
expect(
__noticeTestHooks.detectTemporalUsageFromSearch("notes from today", null),
).toEqual({ triggerSource: "query", triggerReason: "relative_phrase" });
expect(
__noticeTestHooks.detectTemporalUsageFromSearch(
"notes from 2025-04-09",
null,
),
).toEqual({ triggerSource: "query", triggerReason: "date_like_query" });
expect(
__noticeTestHooks.detectTemporalUsageFromMetadata({
event_date: "2025-04-09",
}),
).toEqual({
triggerSource: "metadata",
triggerReason: "date_like_metadata",
});
expect(
__noticeTestHooks.detectTemporalUsageFromSearch("favorite drink", {
AND: [{ created_at: { gte: "2025-04-01" } }],
}),
).toEqual({ triggerSource: "filter", triggerReason: "date_range_filter" });
expect(
__noticeTestHooks.detectTemporalUsageFromSearch("favorite drink", null),
).toBeNull();
expect(
__noticeTestHooks.detectTemporalUsageFromMetadata({
category: "planning",
}),
).toBeNull();
});
it("detectors never raise for cyclic metadata or filters", async () => {
const { __noticeTestHooks } = await import("../src/utils/notices");
const metadata: Record<string, any> = {};
metadata.self = metadata;
const filters: Record<string, any> = {};
filters.AND = [filters];
expect(
__noticeTestHooks.detectTemporalUsageFromMetadata(metadata),
).toBeNull();
expect(
__noticeTestHooks.detectTemporalUsageFromSearch(
"favorite drink",
filters,
),
).toBeNull();
});
});
@@ -61,13 +61,6 @@ describe("telemetry sampling", () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("notice_displayed event fires even at the highest random value", async () => {
randomSpy.mockReturnValue(0.999);
const { captureClientEvent } = await import("../src/utils/telemetry");
await captureClientEvent("notice_displayed", makeInstance());
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("init event payload has sample_rate: 1.0", async () => {
randomSpy.mockReturnValue(0.999);
const { captureClientEvent } = await import("../src/utils/telemetry");
@@ -923,7 +923,6 @@ describe("Memory class – backward compat with all providers", () => {
jest.doMock("../src/utils/telemetry", () => ({
captureClientEvent: jest.fn().mockResolvedValue(undefined),
isTelemetryEnabled: jest.fn(() => false),
}));
MemoryClass = require("../src/memory").Memory;
-4
View File
@@ -16,10 +16,6 @@ class QdrantConfig(BaseModel):
path: Optional[str] = Field("/tmp/qdrant", description="Path for local Qdrant database")
url: Optional[str] = Field(None, description="Full URL for Qdrant server")
api_key: Optional[str] = Field(None, description="API key for Qdrant server")
https: Optional[bool] = Field(
None,
description="Whether to force HTTPS on or off. Explicit schemes in url take precedence.",
)
on_disk: Optional[bool] = Field(False,description="Enables persistent storage. Vectors are kept on disk (True) or in memory (False). Does not delete the local database path.")
@model_validator(mode="before")
+2 -195
View File
@@ -5,7 +5,6 @@ import hashlib
import json
import logging
import os
import time
import uuid
import warnings
from copy import deepcopy
@@ -27,29 +26,6 @@ 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 MEM0_TELEMETRY, capture_event
from mem0.memory.notices import (
PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS,
detect_scale_threshold_from_add_result,
detect_scale_threshold_from_top_k,
detect_decay_usage_from_delete,
detect_decay_usage_from_delete_all,
detect_temporal_usage_from_metadata,
detect_temporal_usage_from_search,
display_decay_usage_notice,
display_decay_usage_notice_async,
display_first_run_notice,
display_first_run_notice_async,
display_performance_slow_query_notice,
display_performance_slow_query_notice_async,
display_scale_threshold_notice,
display_scale_threshold_notice_async,
display_temporal_usage_notice,
display_temporal_usage_notice_async,
get_decay_feature_error_message,
get_decay_feature_error_message_async,
get_temporal_feature_error_message,
get_temporal_feature_error_message_async,
)
from mem0.memory.utils import (
extract_json,
parse_messages,
@@ -373,36 +349,6 @@ def _entity_collection_name(provider: str, collection_name: str) -> str:
setup_config()
logger = logging.getLogger(__name__)
_PROJECT_UPDATE_UNSUPPORTED_ERROR = "Project updates are not supported by the OSS Memory SDK."
class _OSSProject:
def update(
self,
custom_instructions: Optional[str] = None,
custom_categories: Optional[list] = None,
retrieval_criteria: Optional[list] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
):
if decay is True:
raise ValueError(get_decay_feature_error_message("sync", "project.update", "decay"))
raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR)
class _AsyncOSSProject:
async def update(
self,
custom_instructions: Optional[str] = None,
custom_categories: Optional[list] = None,
retrieval_criteria: Optional[list] = None,
multilingual: Optional[bool] = None,
decay: Optional[bool] = None,
):
if decay is True:
raise ValueError(await get_decay_feature_error_message_async("async", "project.update", "decay"))
raise ValueError(_PROJECT_UPDATE_UNSUPPORTED_ERROR)
class Memory(MemoryBase):
def __init__(self, config: MemoryConfig = MemoryConfig()):
@@ -471,10 +417,6 @@ class Memory(MemoryBase):
capture_event("mem0.init", self, {"sync_type": "sync"})
@property
def project(self):
return _OSSProject()
@property
def entity_store(self):
"""Lazily initialize entity store on first use."""
@@ -658,7 +600,6 @@ class Memory(MemoryBase):
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
timestamp: Optional[Any] = None,
infer: bool = True,
memory_type: Optional[str] = None,
prompt: Optional[str] = None,
@@ -676,7 +617,6 @@ class Memory(MemoryBase):
agent_id (str, optional): ID of the agent creating the memory. Defaults to None.
run_id (str, optional): ID of the run creating the memory. Defaults to None.
metadata (dict, optional): Metadata to store with the memory. Defaults to None.
timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS.
infer (bool, optional): If True (default), an LLM is used to extract key facts from
'messages' and decide whether to add, update, or delete related memories.
If False, 'messages' are added as raw memories directly.
@@ -699,10 +639,7 @@ class Memory(MemoryBase):
LLMError: If LLM operations fail.
DatabaseError: If database operations fail.
"""
if timestamp is not None:
raise ValueError(get_temporal_feature_error_message("sync", "add", "timestamp"))
temporal_usage_notice = detect_temporal_usage_from_metadata(metadata)
processed_metadata, effective_filters = _build_filters_and_metadata(
user_id=user_id,
agent_id=agent_id,
@@ -734,13 +671,6 @@ class Memory(MemoryBase):
if agent_id is not None and memory_type == MemoryType.PROCEDURAL.value:
results = self._create_procedural_memory(messages, metadata=processed_metadata, prompt=prompt)
scale_threshold_notice = detect_scale_threshold_from_add_result(self, results)
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "add")
return results
if self.config.llm.config.get("enable_vision"):
@@ -749,13 +679,6 @@ class Memory(MemoryBase):
messages = parse_vision_messages(messages)
vector_store_result = self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt)
scale_threshold_notice = detect_scale_threshold_from_add_result(self, vector_store_result)
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "add", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "add", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "add")
return {"results": vector_store_result}
def _add_to_vector_store(self, messages, metadata, filters, infer, prompt=None):
@@ -1082,7 +1005,6 @@ class Memory(MemoryBase):
capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "sync"})
memory = self.vector_store.get(vector_id=memory_id)
if not memory:
display_first_run_notice(self, "sync", "get")
return None
promoted_payload_keys = [
@@ -1111,7 +1033,6 @@ class Memory(MemoryBase):
if additional_metadata:
result_item["metadata"] = additional_metadata
display_first_run_notice(self, "sync", "get")
return result_item
def get_all(
@@ -1167,7 +1088,6 @@ class Memory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
keys, encoded_ids = process_telemetry_filters(effective_filters)
capture_event(
@@ -1176,10 +1096,6 @@ class Memory(MemoryBase):
all_memories_result = self._get_all_from_vector_store(effective_filters, limit)
if scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "get_all", *scale_threshold_notice)
else:
display_first_run_notice(self, "sync", "get_all")
return {"results": all_memories_result}
def _get_all_from_vector_store(self, filters, limit):
@@ -1238,7 +1154,6 @@ class Memory(MemoryBase):
threshold: float = 0.1,
rerank: bool = False,
explain: bool = False,
reference_date: Optional[Any] = None,
**kwargs,
):
"""
@@ -1270,7 +1185,6 @@ class Memory(MemoryBase):
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
rerank (bool, optional): Whether to rerank results. Defaults to False.
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS.
Returns:
dict: A dictionary containing the search results under a "results" key.
@@ -1280,16 +1194,12 @@ class Memory(MemoryBase):
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
if reference_date is not None:
raise ValueError(get_temporal_feature_error_message("sync", "search", "reference_date"))
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
query = _validate_and_trim_search_query(query)
temporal_usage_notice = detect_temporal_usage_from_search(query, filters)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
@@ -1312,7 +1222,6 @@ class Memory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
# Apply enhanced metadata filtering if advanced operators are detected
if self._has_advanced_operators(effective_filters):
@@ -1341,9 +1250,7 @@ class Memory(MemoryBase):
},
)
search_start = time.perf_counter()
original_memories = self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
search_elapsed_seconds = time.perf_counter() - search_start
# Apply reranking if enabled and reranker is available
if rerank and self.reranker and original_memories:
@@ -1353,21 +1260,6 @@ class Memory(MemoryBase):
except Exception as e:
logger.warning(f"Reranking failed, using original results: {e}")
if temporal_usage_notice:
display_temporal_usage_notice(self, "sync", "search", *temporal_usage_notice)
elif scale_threshold_notice:
display_scale_threshold_notice(self, "sync", "search", *scale_threshold_notice)
elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS:
display_performance_slow_query_notice(
self,
"sync",
"search",
search_elapsed_seconds,
top_k,
len(original_memories),
)
else:
display_first_run_notice(self, "sync", "search")
return {"results": original_memories}
def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]:
@@ -1677,7 +1569,6 @@ class Memory(MemoryBase):
existing_embeddings = {data: self.embedding_model.embed(data, "update")}
self._update_memory(memory_id, data, existing_embeddings, metadata)
display_first_run_notice(self, "sync", "update")
return {"message": "Memory updated successfully!"}
def delete(self, memory_id):
@@ -1694,11 +1585,6 @@ class Memory(MemoryBase):
raise ValueError(f"Memory with id {memory_id} not found")
self._delete_memory(memory_id, existing_memory)
decay_usage_notice = detect_decay_usage_from_delete()
if decay_usage_notice:
display_decay_usage_notice(self, "sync", "delete", *decay_usage_notice)
else:
display_first_run_notice(self, "sync", "delete")
return {"message": "Memory deleted successfully!"}
def delete_all(self, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None):
@@ -1732,11 +1618,6 @@ class Memory(MemoryBase):
logger.info(f"Deleted {len(memories)} memories")
decay_usage_notice = detect_decay_usage_from_delete_all(len(memories))
if decay_usage_notice:
display_decay_usage_notice(self, "sync", "delete_all", *decay_usage_notice)
else:
display_first_run_notice(self, "sync", "delete_all")
return {"message": "Memories deleted successfully!"}
def history(self, memory_id):
@@ -1750,9 +1631,7 @@ class Memory(MemoryBase):
list: List of changes for the memory.
"""
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "sync"})
history = self.db.get_history(memory_id)
display_first_run_notice(self, "sync", "history")
return history
return self.db.get_history(memory_id)
def _create_memory(self, data, existing_embeddings, metadata=None):
logger.debug(f"Creating memory with {data=}")
@@ -1952,7 +1831,6 @@ class Memory(MemoryBase):
self._entity_store = None
capture_event("mem0.reset", self, {"sync_type": "sync"})
display_first_run_notice(self, "sync", "reset")
def close(self):
"""Release resources held by this Memory instance (SQLite connections, etc.)."""
@@ -2011,10 +1889,6 @@ class AsyncMemory(MemoryBase):
capture_event("mem0.init", self, {"sync_type": "async"})
@property
def project(self):
return _AsyncOSSProject()
@property
def entity_store(self):
"""Lazily initialize entity store on first use."""
@@ -2184,7 +2058,6 @@ class AsyncMemory(MemoryBase):
agent_id: Optional[str] = None,
run_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
timestamp: Optional[Any] = None,
infer: bool = True,
memory_type: Optional[str] = None,
prompt: Optional[str] = None,
@@ -2199,7 +2072,6 @@ class AsyncMemory(MemoryBase):
agent_id (str, optional): ID of the agent creating the memory. Defaults to None.
run_id (str, optional): ID of the run creating the memory. Defaults to None.
metadata (dict, optional): Metadata to store with the memory. Defaults to None.
timestamp (Any, optional): Platform-only temporal parameter. Not supported in OSS.
infer (bool, optional): Whether to infer the memories. Defaults to True.
memory_type (str, optional): Type of memory to create. Defaults to None.
Pass "procedural_memory" to create procedural memories.
@@ -2208,10 +2080,6 @@ class AsyncMemory(MemoryBase):
Returns:
dict: A dictionary containing the result of the memory addition operation.
"""
if timestamp is not None:
raise ValueError(await get_temporal_feature_error_message_async("async", "add", "timestamp"))
temporal_usage_notice = detect_temporal_usage_from_metadata(metadata)
processed_metadata, effective_filters = _build_filters_and_metadata(
user_id=user_id, agent_id=agent_id, run_id=run_id, input_metadata=metadata
)
@@ -2239,13 +2107,6 @@ class AsyncMemory(MemoryBase):
results = await self._create_procedural_memory(
messages, metadata=processed_metadata, prompt=prompt, llm=llm
)
scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, results)
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "add")
return results
if self.config.llm.config.get("enable_vision"):
@@ -2254,13 +2115,6 @@ class AsyncMemory(MemoryBase):
messages = parse_vision_messages(messages)
vector_store_result = await self._add_to_vector_store(messages, processed_metadata, effective_filters, infer, prompt=prompt)
scale_threshold_notice = await asyncio.to_thread(detect_scale_threshold_from_add_result, self, vector_store_result)
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "add", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "add", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "add")
return {"results": vector_store_result}
async def _add_to_vector_store(
@@ -2594,7 +2448,6 @@ class AsyncMemory(MemoryBase):
capture_event("mem0.get", self, {"memory_id": memory_id, "sync_type": "async"})
memory = await asyncio.to_thread(self.vector_store.get, vector_id=memory_id)
if not memory:
await display_first_run_notice_async(self, "async", "get")
return None
promoted_payload_keys = [
@@ -2623,7 +2476,6 @@ class AsyncMemory(MemoryBase):
if additional_metadata:
result_item["metadata"] = additional_metadata
await display_first_run_notice_async(self, "async", "get")
return result_item
async def get_all(
@@ -2679,7 +2531,6 @@ class AsyncMemory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
keys, encoded_ids = process_telemetry_filters(effective_filters)
capture_event(
@@ -2688,10 +2539,6 @@ class AsyncMemory(MemoryBase):
all_memories_result = await self._get_all_from_vector_store(effective_filters, limit)
if scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "get_all", *scale_threshold_notice)
else:
await display_first_run_notice_async(self, "async", "get_all")
return {"results": all_memories_result}
async def _get_all_from_vector_store(self, filters, limit):
@@ -2750,7 +2597,6 @@ class AsyncMemory(MemoryBase):
threshold: float = 0.1,
rerank: bool = False,
explain: bool = False,
reference_date: Optional[Any] = None,
**kwargs,
):
"""
@@ -2782,7 +2628,6 @@ class AsyncMemory(MemoryBase):
threshold (float, optional): Minimum score for a memory to be included. Defaults to 0.1.
rerank (bool, optional): Whether to rerank results. Defaults to False.
explain (bool, optional): Whether to include score_details for each result. Defaults to False.
reference_date (Any, optional): Platform-only temporal parameter. Not supported in OSS.
Returns:
dict: A dictionary containing the search results under a "results" key.
@@ -2792,18 +2637,12 @@ class AsyncMemory(MemoryBase):
ValueError: If filters doesn't contain at least one of user_id, agent_id, run_id,
or if threshold/top_k values are invalid.
"""
if reference_date is not None:
raise ValueError(
await get_temporal_feature_error_message_async("async", "search", "reference_date")
)
# Reject top-level entity params - must use filters instead
_reject_top_level_entity_params(kwargs, "search")
# Validate search parameters (before applying defaults)
_validate_search_params(threshold=threshold, top_k=top_k)
query = _validate_and_trim_search_query(query)
temporal_usage_notice = detect_temporal_usage_from_search(query, filters)
# Validate and trim entity IDs in filters
effective_filters = filters.copy() if filters else {}
@@ -2828,7 +2667,6 @@ class AsyncMemory(MemoryBase):
)
limit = top_k
scale_threshold_notice = detect_scale_threshold_from_top_k(top_k)
# Apply enhanced metadata filtering if advanced operators are detected
if self._has_advanced_operators(effective_filters):
@@ -2857,9 +2695,7 @@ class AsyncMemory(MemoryBase):
},
)
search_start = time.perf_counter()
original_memories = await self._search_vector_store(query, effective_filters, limit, threshold, explain=explain)
search_elapsed_seconds = time.perf_counter() - search_start
# Apply reranking if enabled and reranker is available
if rerank and self.reranker and original_memories:
@@ -2872,21 +2708,6 @@ class AsyncMemory(MemoryBase):
except Exception as e:
logger.warning(f"Reranking failed, using original results: {e}")
if temporal_usage_notice:
await display_temporal_usage_notice_async(self, "async", "search", *temporal_usage_notice)
elif scale_threshold_notice:
await display_scale_threshold_notice_async(self, "async", "search", *scale_threshold_notice)
elif search_elapsed_seconds > PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS:
await display_performance_slow_query_notice_async(
self,
"async",
"search",
search_elapsed_seconds,
top_k,
len(original_memories),
)
else:
await display_first_run_notice_async(self, "async", "search")
return {"results": original_memories}
def _process_metadata_filters(self, metadata_filters: Dict[str, Any]) -> Dict[str, Any]:
@@ -3187,7 +3008,6 @@ class AsyncMemory(MemoryBase):
existing_embeddings = {data: embeddings}
await self._update_memory(memory_id, data, existing_embeddings, metadata)
await display_first_run_notice_async(self, "async", "update")
return {"message": "Memory updated successfully!"}
async def delete(self, memory_id):
@@ -3204,11 +3024,6 @@ class AsyncMemory(MemoryBase):
raise ValueError(f"Memory with id {memory_id} not found")
await self._delete_memory(memory_id, existing_memory)
decay_usage_notice = detect_decay_usage_from_delete()
if decay_usage_notice:
await display_decay_usage_notice_async(self, "async", "delete", *decay_usage_notice)
else:
await display_first_run_notice_async(self, "async", "delete")
return {"message": "Memory deleted successfully!"}
async def delete_all(self, user_id=None, agent_id=None, run_id=None):
@@ -3245,11 +3060,6 @@ class AsyncMemory(MemoryBase):
logger.info(f"Deleted {len(memories[0])} memories")
decay_usage_notice = detect_decay_usage_from_delete_all(len(memories[0]))
if decay_usage_notice:
await display_decay_usage_notice_async(self, "async", "delete_all", *decay_usage_notice)
else:
await display_first_run_notice_async(self, "async", "delete_all")
return {"message": "Memories deleted successfully!"}
async def history(self, memory_id):
@@ -3263,9 +3073,7 @@ class AsyncMemory(MemoryBase):
list: List of changes for the memory.
"""
capture_event("mem0.history", self, {"memory_id": memory_id, "sync_type": "async"})
history = await asyncio.to_thread(self.db.get_history, memory_id)
await display_first_run_notice_async(self, "async", "history")
return history
return await asyncio.to_thread(self.db.get_history, memory_id)
async def _create_memory(self, data, existing_embeddings, metadata=None):
logger.debug(f"Creating memory with {data=}")
@@ -3483,7 +3291,6 @@ class AsyncMemory(MemoryBase):
)
capture_event("mem0.reset", self, {"sync_type": "async"})
await display_first_run_notice_async(self, "async", "reset")
def close(self):
"""Release resources held by this AsyncMemory instance."""
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -1,7 +1,6 @@
import json
import logging
import os
import tempfile
import uuid
from hashlib import sha256
@@ -35,21 +34,10 @@ def _load_config():
def _write_config(config):
"""Best-effort write of ~/.mem0/config.json. Never raises."""
path = _config_path()
temp_path = None
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with tempfile.NamedTemporaryFile("w", dir=os.path.dirname(path), delete=False) as f:
temp_path = f.name
with open(path, "w") as f:
json.dump(config, f, indent=4)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
except Exception as e:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
_logger.debug("Failed to write mem0 config %s: %s", path, e)
+9 -15
View File
@@ -14,7 +14,6 @@ from mem0.memory.setup import get_or_create_user_id
MEM0_TELEMETRY = os.environ.get("MEM0_TELEMETRY", "True")
PROJECT_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
HOST = "https://us.i.posthog.com"
FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS = 0.5
if isinstance(MEM0_TELEMETRY, str):
MEM0_TELEMETRY = MEM0_TELEMETRY.lower() in ("true", "1", "yes")
@@ -50,9 +49,7 @@ MEM0_TELEMETRY_SAMPLE_RATE = _parse_sample_rate(os.environ.get("MEM0_TELEMETRY_S
# Events that bypass sampling and always fire. Keep this set in sync with the
# event names passed to capture_event() in mem0/memory/main.py.
# $identify is included so PostHog person-merging is never lost to sampling.
_LIFECYCLE_EVENTS = frozenset(
{"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "mem0.notice_displayed", "$identify"}
)
_LIFECYCLE_EVENTS = frozenset({"mem0.init", "mem0.reset", "mem0._create_procedural_memory", "$identify"})
def _sampling_before_send(msg):
@@ -80,15 +77,15 @@ class AnonymousTelemetry:
self.user_id = None
return
self.posthog = Posthog(
project_api_key=PROJECT_API_KEY,
host=HOST,
before_send=before_send,
feature_flags_request_timeout_seconds=FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS,
)
try:
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST, before_send=before_send)
except TypeError:
# posthog <4.5.0 does not accept before_send; fall back without sampling.
_logger.debug("posthog.Posthog does not accept before_send; upgrade to >=4.5.0 for sampling")
self.posthog = Posthog(project_api_key=PROJECT_API_KEY, host=HOST)
self.user_id = get_or_create_user_id(vector_store)
def capture_event(self, event_name, properties=None, user_email=None, flags=None):
def capture_event(self, event_name, properties=None, user_email=None):
if self.posthog is None:
return
@@ -112,10 +109,7 @@ class AnonymousTelemetry:
**properties,
}
try:
capture_kwargs = {"distinct_id": distinct_id, "properties": properties}
if flags is not None:
capture_kwargs["flags"] = flags
self.posthog.capture(event_name, **capture_kwargs)
self.posthog.capture(distinct_id=distinct_id, event=event_name, properties=properties)
except Exception as e:
_logger.debug("Failed to capture telemetry event %r: %s", event_name, e)
+4 -13
View File
@@ -179,20 +179,11 @@ def parse_vision_messages(messages, llm=None, vision_details="auto"):
# Handle message content
if isinstance(msg["content"], list):
if llm is None:
text_parts = [
part["text"] for part in msg["content"]
if isinstance(part, dict) and part.get("type") == "text"
]
if not text_parts:
continue
returned_messages.append({"role": msg["role"], "content": " ".join(text_parts)})
else:
description = get_image_description(msg, llm, vision_details)
returned_messages.append({"role": msg["role"], "content": description})
# Multiple image URLs in content
description = get_image_description(msg, llm, vision_details)
returned_messages.append({"role": msg["role"], "content": description})
elif isinstance(msg["content"], dict) and msg["content"].get("type") == "image_url":
if llm is None:
continue
# Single image content
image_url = msg["content"]["image_url"]["url"]
try:
description = get_image_description(image_url, llm, vision_details)
-5
View File
@@ -37,7 +37,6 @@ class Qdrant(VectorStoreBase):
path: str = None,
url: str = None,
api_key: str = None,
https: bool | None = None,
on_disk: bool = False,
):
"""
@@ -52,8 +51,6 @@ class Qdrant(VectorStoreBase):
path (str, optional): Path for local Qdrant database. Defaults to None.
url (str, optional): Full URL for Qdrant server. Defaults to None.
api_key (str, optional): API key for Qdrant server. Defaults to None.
https (bool, optional): Whether to force HTTPS on or off. Explicit schemes in url take precedence.
Defaults to None.
on_disk (bool, optional): Enables persistent storage. Vectors are stored on disk (True) or in memory (False).
Does not delete the local database path. Defaults to False.
"""
@@ -69,8 +66,6 @@ class Qdrant(VectorStoreBase):
if host and port:
params["host"] = host
params["port"] = port
if https is not None:
params["https"] = https
if not params:
params["path"] = path
Generated
+9 -9
View File
@@ -3494,15 +3494,15 @@ pytest = ["pytest (>=7.0.0)", "rich (>=13.9.4,<14.0.0)"]
[[package]]
name = "litellm"
version = "1.83.7"
version = "1.88.1"
description = "Library to easily interface with LLM API providers"
optional = true
python-versions = "<4.0,>=3.9"
python-versions = "<3.14,>=3.10"
groups = ["main"]
markers = "extra == \"llms\""
files = [
{file = "litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111"},
{file = "litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633"},
{file = "litellm-1.88.1-py3-none-any.whl", hash = "sha256:369b84e57d9426582ddc35e731956ddb6618cda97cc44e4e4d2dfa75982a6e3a"},
{file = "litellm-1.88.1.tar.gz", hash = "sha256:89c6b74cc7912d6365793006ff951c0450fe847625008dfe49de8a7dc4529aa5"},
]
[package.dependencies]
@@ -3515,7 +3515,7 @@ jinja2 = "3.1.6"
jsonschema = "4.23.0"
openai = "2.30.0"
pydantic = "2.12.5"
python-dotenv = "1.0.1"
python-dotenv = "1.2.2"
tiktoken = "0.12.0"
tokenizers = "0.22.2"
@@ -6866,15 +6866,15 @@ six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "1.0.1"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = true
python-versions = ">=3.8"
python-versions = ">=3.10"
groups = ["main"]
markers = "extra == \"vector-stores\" or extra == \"llms\" or extra == \"extras\""
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.0.6"
version = "2.0.5"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
@@ -17,7 +17,7 @@ dependencies = [
"qdrant-client>=1.12.0",
"pydantic>=2.7.3",
"openai>=1.90.0",
"posthog>=7.14.0",
"posthog>=4.5.0",
"pytz>=2024.1",
"sqlalchemy>=2.0.31",
"protobuf>=5.29.6,<7.0.0",
@@ -55,7 +55,7 @@ vector_stores = [
llms = [
"groq>=0.3.0",
"together>=0.2.10",
"litellm>=1.83.7",
"litellm>=1.88.1",
"openai>=1.90.0",
"ollama>=0.3.0",
"vertexai>=0.1.0",
+1 -3
View File
@@ -22,9 +22,8 @@ Invoked as a slash command to execute a specific end-to-end workflow. These do r
|-------|---------|---------|
| [`mem0-integrate`](./mem0-integrate/) | `/mem0-integrate` — wire Mem0 into an existing repo via TDD | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-integrate` |
| [`mem0-test-integration`](./mem0-test-integration/) | `/mem0-test-integration` — verify what `/mem0-integrate` produced | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-test-integration` |
| [`mem0-oss-to-platform`](./mem0-oss-to-platform/) | `/mem0-oss-to-platform` — migrate a project from Mem0 OSS to the hosted Platform SDK | `npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform` |
The `mem0-integrate` and `mem0-test-integration` skills are designed to run in sequence on the same workspace:
The two pipeline skills are designed to run in sequence on the same workspace:
```
/mem0-integrate → mem0-integrate/<slug> branch + .mem0-integration/ artifacts
@@ -37,7 +36,6 @@ The `mem0-integrate` and `mem0-test-integration` skills are designed to run in s
- **Using the terminal CLI?** → `mem0-cli`
- **Building with `@ai-sdk/*`?** → `mem0-vercel-ai-sdk`
- **Want the assistant to wire Mem0 into an existing repo for you?** → `mem0-integrate`, then `mem0-test-integration`
- **Already using Mem0 OSS and want to move to the hosted Platform?** → `mem0-oss-to-platform`
## Links
-189
View File
@@ -1,189 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2024 Mem0.ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-79
View File
@@ -1,79 +0,0 @@
# mem0-oss-to-platform — Pipeline Skill
Migrate a project from the Mem0 Open Source (self-hosted) SDK to the Mem0 Platform (hosted) SDK, end to end. The skill audits where Mem0 is used, writes a reviewable migration plan, and executes it after you approve.
> **This is a pipeline skill, not a reference skill.** Invoke it when you want your agent to migrate an existing project's Mem0 integration from OSS to the Platform. For day-to-day SDK coding help, install [`mem0`](../mem0/SKILL.md) instead.
>
> **Part of the Mem0 Skill Graph:**
> - Reference: [mem0](../mem0/SKILL.md) · [mem0-cli](../mem0-cli/SKILL.md) · [mem0-vercel-ai-sdk](../mem0-vercel-ai-sdk/SKILL.md)
> - Pipeline: [mem0-integrate](../mem0-integrate/SKILL.md) → [mem0-test-integration](../mem0-test-integration/SKILL.md) · **mem0-oss-to-platform** (this skill)
## What This Skill Does
When invoked, your assistant will:
- **Discover** every place Mem0 is used in the project — imports, client init, config blocks, call sites, dependencies, env, and local infra
- **Verify** the exact API against the installed SDK rather than guessing
- **Map** each OSS `Memory` usage to its hosted `MemoryClient` equivalent (Python and TypeScript)
- **Flag** everything that isn't a clean 1:1 and needs a human decision
- **Write** a reviewable `MEM0_MIGRATION_PLAN.md`, then **execute it after you approve** — strictly scoped to the Mem0 integration, with no unrelated refactors
## When to Use
Trigger phrases:
- "Migrate my Mem0 setup to the Platform"
- "Switch from self-hosted Mem0 to MemoryClient"
- "Use my Mem0 API key instead of a local Qdrant"
- "Move Mem0 to the hosted/managed service"
Do **not** use this skill for general SDK usage (install [`mem0`](../mem0/SKILL.md)), or to add Mem0 to a repo that doesn't use it yet (use [`mem0-integrate`](../mem0-integrate/SKILL.md)).
## Installation
### CLI (Claude Code, Codex, OpenCode, OpenClaw, or any tool that supports skills)
```bash
npx skills add https://github.com/mem0ai/mem0 --skill mem0-oss-to-platform
```
### Claude.ai
1. Download this `skills/mem0-oss-to-platform` folder as a ZIP
2. Go to **Settings > Capabilities > Skills**
3. Click **Upload skill** and select the ZIP
### Claude API (Skills API)
```bash
curl -X POST https://api.anthropic.com/v1/skills \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "mem0-oss-to-platform", "source": "https://github.com/mem0ai/mem0/tree/main/skills/mem0-oss-to-platform"}'
```
### Prerequisites
- A Mem0 Platform API key ([get one](https://app.mem0.ai/dashboard/api-keys))
- An existing project that uses the Mem0 OSS SDK
## Workflow
```
(invoke skill) → audits the repo's Mem0 usage,
writes MEM0_MIGRATION_PLAN.md,
stops for your review
(approve) → executes the plan and verifies
(compile/import, real-API smoke test)
```
## Links
- [Mem0 Platform Dashboard](https://app.mem0.ai)
- [Mem0 Documentation](https://docs.mem0.ai)
- [OSS → Platform migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3)
- [Platform vs OSS comparison](https://docs.mem0.ai/platform/platform-vs-oss)
## License
Apache-2.0
-120
View File
@@ -1,120 +0,0 @@
---
name: mem0-oss-to-platform
description: >-
Plan and then execute a migration of a project from the mem0 open-source / self-hosted SDK
(the local `Memory` class) to the mem0 Platform / hosted / managed SDK (the `MemoryClient`
class). Use this whenever a developer wants to move, switch, or migrate their mem0 usage off
OSS/self-hosted to the hosted API — e.g. "migrate my mem0 setup to the platform", "switch from
self-hosted mem0 to MemoryClient", "use my mem0 API key instead of a local Qdrant", "move mem0
to the cloud/hosted/managed service", or "replace my local mem0 vector store + embedder config
with the platform". Applies to Python (`from mem0 import Memory` → `from mem0 import MemoryClient`)
and TypeScript/JavaScript (`import { Memory } from "mem0ai/oss"` → `import MemoryClient from "mem0ai"`).
Trigger even when the user doesn't say the word "migrate" but clearly wants their existing mem0
integration to run against the hosted platform. It first produces a reviewable migration plan,
then executes it after the developer approves. Strictly scoped to the mem0 integration — it does
not refactor, restructure, or "improve" any unrelated code.
---
# Migrate mem0 OSS → mem0 Platform (hosted)
This skill migrates a project's memory layer from the **self-hosted mem0 OSS SDK** to the
**hosted mem0 Platform SDK**, working for any project shape — an agent, a RAG pipeline, an API
service, a chatbot, a background worker. You discover where mem0 is actually used, write a plan the
developer reviews, and then execute it on approval.
## The mental model (read this first — it's why the migration is shaped the way it is)
OSS mem0 means **the developer runs the whole memory stack themselves**: a vector store
(Qdrant/pgvector/Chroma/…), an embedder, an LLM for fact extraction, and a local history DB. All of
that is wired up in a config object passed to `Memory`.
The Platform means **mem0 runs that stack for them**. The developer just holds an **API key**. So
the migration is mostly *subtraction*: the local infrastructure config collapses into a single
`MemoryClient(api_key=...)`. The method calls stay recognizable (`add`/`search`/`get_all`/…), but a
few parameter conventions tighten up and the return values are server responses.
So the core of every migration is:
1. `Memory` / `Memory.from_config({...})` → `MemoryClient()` (reads the API key from the env).
2. Delete the local `vector_store` / `llm` / `embedder` / `graph_store` / `history_db_path` config.
3. Fix up each call site to the hosted call convention (entity IDs into `filters`, pagination, etc.).
4. Flag everything that *isn't* a clean 1:1 so the developer can decide (see `references/gotchas.md`).
**Scope discipline:** touch only mem0-related code, config, dependencies, and env. Preserve the
project's existing behavior, structure, and style. Do not rename things, "tidy" nearby code, or
change the app's logic. The developer asked to swap a backend, not to refactor their project.
## Workflow
Work through these phases in order. Phases 1–4 produce the plan; phase 5 runs only after approval.
### Phase 0 — Prerequisite check
The hosted SDK needs a mem0 API key (`MEM0_API_KEY`, obtainable at https://app.mem0.ai). Confirm
the developer has one. You don't need the key value to write the plan, but flag in the plan that it
must be set (in `.env` / secrets manager, never hardcoded) before execution and verification.
### Phase 1 — Discover the mem0 footprint
Do not assume the layout. Find every place mem0 appears. Detect the language and the **installed**
version first, then sweep for usage. Concretely, search for:
- **Imports / instantiation:** `from mem0 import Memory`, `Memory.from_config`, `Memory(`,
`import ... from "mem0ai"`, `from "mem0ai/oss"`, `new Memory(`.
- **Config blocks:** keys like `vector_store`/`vectorStore`, `embedder`, `llm`, `graph_store`/
`graphStore`, `history_db_path`, `historyStore`, `custom_fact_extraction_prompt`,
`custom_update_memory_prompt`, `enable_graph`.
- **Every call site:** `.add(`, `.search(`, `.get_all(`/`.getAll(`, `.delete_all(`/`.deleteAll(`,
`.get(`, `.update(`, `.delete(`, `.reset(`, `.history(`.
- **Dependencies & env:** `requirements.txt`/`pyproject.toml`/`package.json` for `mem0ai` and any
local-infra deps that exist *only* for mem0 (e.g. `qdrant-client`, `chromadb`); `.env`/config for
things like `OPENAI_API_KEY` used by the local embedder/LLM; any docker-compose service (e.g. a
Qdrant container) that exists only to back mem0.
Use Grep/Glob broadly; a single missed call site is a runtime break later. Record `file:line` for
each finding — the plan's inventory is built from this.
### Phase 2 — Verify the API against the installed SDK (don't guess)
Versions drift, and the OSS and hosted classes have subtly different signatures. Before mapping,
confirm the **real** signatures of the installed package rather than trusting memory:
- **Python:** `python -c "import inspect; from mem0 import MemoryClient; print(inspect.signature(MemoryClient.search))"`
for each method you'll touch, and read the installed source under
`site-packages/mem0/client/main.py` if anything is ambiguous (e.g. whether a method *rejects*
top-level entity params). Also check the OSS side the project currently uses.
- **TypeScript:** read the installed types/dist under `node_modules/mem0ai/` to confirm option names
(`limit` vs `topK`, `userId` vs a nested `filters`) and the default vs `mem0ai/oss` export.
This verification step is the single most important habit — it's what keeps the plan correct across
mem0 versions. Then consult `references/api-mapping.md` for the OSS→hosted translation of each
method (Python and TypeScript), and the official guide at https://docs.mem0.ai/migration/oss-v2-to-v3.
### Phase 3 — Map each site and flag the gaps
For every call site and config block from Phase 1, determine the hosted equivalent using the
mapping. Most calls map cleanly. Some don't — and those matter more than the mechanical edits.
Read `references/gotchas.md` and flag anything that needs a human decision: self-hosted/data-
residency setups, local model choices moving server-side, graph-memory usage, custom prompts, hot-
path calls that now make network round-trips, and **existing locally-stored memories not carrying
over** (data migration is out of scope unless the developer asks — note it, don't silently attempt it).
### Phase 4 — Write the plan and stop
Write the full plan to `MEM0_MIGRATION_PLAN.md` at the repo root, following the structure in
`references/plan-template.md`. It must be concrete enough to execute from and honest about the gaps.
Then **stop and present it for review.** Do not start editing code in the same turn — the whole
point is that the developer reads and approves the plan first.
### Phase 5 — Execute on approval (guided)
Once the developer approves (they may ask for changes first — incorporate them), execute the plan:
- Make the edits file by file, staying strictly within mem0 scope.
- Update dependencies and env (`MEM0_API_KEY`; remove now-dead local-infra deps/services only if
they exist solely for mem0 and you're confident).
- **Verify**, mirroring how you'd confirm any backend swap:
- It imports / type-checks / byte-compiles.
- A smoke test exercises `add` → `search`/`get_all` → `delete_all` against the hosted API with a
real `MEM0_API_KEY`, and the app's own entry point still runs.
- No local mem0 storage directory gets created anymore (e.g. a `.mem0/`, local Qdrant path) —
proof the memory really lives on the platform now.
- Report what changed, what was verified, and any flagged concerns the developer still needs to act
on (e.g. configuring custom instructions in the dashboard, migrating old data).
## Reference files
- `references/api-mapping.md` — exact OSS→hosted method/param/return mapping for Python and
TypeScript, plus dependency and env changes. Read during Phase 2–3.
- `references/gotchas.md` — the things that aren't a clean 1:1 and need a human decision. Read
during Phase 3 so the plan's "Concerns" section is complete.
- `references/plan-template.md` — the exact structure for `MEM0_MIGRATION_PLAN.md`. Use in Phase 4.
@@ -1,117 +0,0 @@
# OSS → Platform API mapping
Exact translation of the mem0 OSS (self-hosted `Memory`) API to the hosted `MemoryClient` API.
**Always confirm against the installed package** (see SKILL.md Phase 2) — versions drift. The facts
below match `mem0ai` 2.0.x (the v3 platform API) and the official guide:
https://docs.mem0.ai/migration/oss-v2-to-v3
## Contents
- [Python](#python)
- [TypeScript / JavaScript](#typescript--javascript)
- [Return shapes](#return-shapes)
- [Dependencies & environment](#dependencies--environment)
- [v2→v3 default/behavior changes](#v2v3-defaultbehavior-changes)
---
## Python
### Import & client construction
```python
# OSS (self-hosted)
from mem0 import Memory
memory = Memory() # or:
memory = Memory.from_config({ # all of this local config disappears
"vector_store": {...},
"llm": {...},
"embedder": {...},
"history_db_path": "...",
})
# Platform (hosted)
from mem0 import MemoryClient
memory = MemoryClient() # reads MEM0_API_KEY from the env
# or: MemoryClient(api_key="...")
```
Notes:
- The client reads `MEM0_API_KEY` from the environment when `api_key` is omitted.
- **Drop** `vector_store`, `llm`, `embedder`, `graph_store`, `history_db_path` — these are managed
server-side now.
- **Drop** `org_id` / `project_id` constructor args if present — they're resolved from the API key
in v3.
- For async codebases, use `AsyncMemoryClient` (same methods, `await`-ed).
### Method calls
| Operation | OSS `Memory` | Hosted `MemoryClient` |
|---|---|---|
| add | `memory.add(messages, user_id="u")` | `memory.add(messages, user_id="u")` — unchanged (top-level entity IDs accepted) |
| search | `memory.search(q, user_id="u", limit=N)` *(older)* or `…, filters={"user_id":"u"}, top_k=N` *(newer)* | `memory.search(q, filters={"user_id": "u"}, top_k=N)` — entity IDs **must** be inside `filters`; top-level `user_id`/`agent_id`/`app_id`/`run_id` raise `ValueError` |
| get_all | `memory.get_all(user_id="u")` or `…, filters={"user_id":"u"}` | `memory.get_all(filters={"user_id": "u"}, page=1, page_size=N)` — entity IDs in `filters`; paginated with `page`/`page_size` (**not** `top_k`) |
| delete_all | `memory.delete_all(user_id="u")` | `memory.delete_all(user_id="u")` — unchanged |
| get | `memory.get(memory_id)` | `memory.get(memory_id)` |
| update | `memory.update(memory_id, data=...)` | `memory.update(memory_id, text=...)` — confirm param name against installed sig |
| delete | `memory.delete(memory_id)` | `memory.delete(memory_id)` |
| reset | `memory.reset()` (wipes the local store) | **No global reset.** Use `memory.delete_all(filters=...)` scoped to the relevant entity. Flag this. |
Key rule: for **search** and **get_all**, the hosted client requires entity IDs (`user_id`,
`agent_id`, `app_id`, `run_id`) inside a `filters` dict and will raise if you pass them top-level.
For **add** and **delete_all**, top-level entity IDs are accepted.
---
## TypeScript / JavaScript
The hosted and OSS SDKs ship in the same `mem0ai` npm package, distinguished by import path.
Confirm option names against `node_modules/mem0ai/` types.
### Import & client construction
```typescript
// OSS (self-hosted) — note the "/oss" subpath
import { Memory } from "mem0ai/oss";
const memory = new Memory({ /* vectorStore, embedder, llm, historyStore … */ });
// Platform (hosted) — default export from the package root
import MemoryClient from "mem0ai";
const memory = new MemoryClient({ apiKey: process.env.MEM0_API_KEY });
// Drop organizationId / projectId — resolved from the API key in v3.
```
### Method calls (option-object differences)
| Operation | OSS / old client | Hosted client (v3) |
|---|---|---|
| add | `memory.add(messages, { userId: "u" })` | `memory.add(messages, { userId: "u" })` — unchanged |
| search | `memory.search(q, { userId: "u", limit: 20 })` | `memory.search(q, { filters: { userId: "u" }, topK: 20 })` — entity IDs into `filters`; `limit` → `topK` |
| getAll | `memory.getAll({ userId: "u" })` | `memory.getAll({ filters: { userId: "u" } })` — entity IDs into `filters` |
| deleteAll | `memory.deleteAll({ userId: "u" })` | `memory.deleteAll({ userId: "u" })` |
| get / update / delete | `memory.get(id)` etc. | same, by memory id |
Also drop legacy options that no longer apply on v3: `async_mode`, `output_format`, `enable_graph`.
---
## Return shapes
- `search(...)` and `get_all(...)` return `{"results": [...]}`; each item has at least a `memory`
(text) field, plus `id` and (for search) `score`. Code that reads `result["results"]` and pulls
`item["memory"]` keeps working.
- `get_all(...)` on the hosted client is paginated: `{"count", "next", "previous", "results": [...]}`.
- `add(...)` returns the created memories. On v3 it returns **only ADD events** — if the old code
branched on `event == "UPDATE"` / `"DELETE"` from `add()` results, that branch is now dead.
---
## Dependencies & environment
- **Keep** the `mem0ai` dependency — `MemoryClient` ships in the same package. No version bump is
required just to use the hosted client (confirm the installed version supports it).
- **Remove** dependencies that existed *only* to back the local mem0 store/embedder/LLM and are now
unused (e.g. `qdrant-client`, `chromadb`, a local embedding lib). Only remove what you can confirm
is unused elsewhere.
- **Add** `MEM0_API_KEY` to the environment / `.env.example` / secrets manager / deployment config.
- Local-infra services (e.g. a Qdrant docker-compose service) that existed only for mem0 can be
retired — flag this rather than deleting infrastructure unilaterally.
## v2→v3 default/behavior changes
From the official migration guide — surface any that affect the project:
- Python `top_k` default changed 100 → 20; TS `limit` renamed to `topK`.
- New `threshold` default `0.1` (was none); new `rerank` default `false` (was true).
- `custom_fact_extraction_prompt` → `custom_instructions`; `custom_update_memory_prompt` deprecated.
- Graph memory (`enable_graph`, `graph_store`) removed from the OSS v3 surface — see gotchas.
@@ -1,63 +0,0 @@
# Gotchas — the things that aren't a clean 1:1
Swapping `Memory` for `MemoryClient` is mostly mechanical. These items are *not* mechanical: they
change behavior, move responsibility off the developer's machine, or have no direct equivalent.
Every one that applies to the project belongs in the plan's **"Concerns & decisions needed"**
section, phrased as a decision for the developer — never silently resolved.
## 1. Data does not migrate with the code
Migrating the *code* does not move the *memories*. Anything stored in the local vector store /
history DB stays there; the hosted account starts empty. This is the most surprising gap, so call
it out prominently. Data migration is **out of scope** unless the developer explicitly asks. If they
do, the rough path is: read everything from the OSS store (`get_all` per user/entity) and re-`add`
it to the hosted client — but treat that as a separate, opt-in task.
## 2. Self-hosting / data residency
A local or self-hosted vector store sometimes exists *on purpose* — compliance, data residency, air-
gapped deployment, cost. Moving to the managed platform sends memory content to mem0's servers.
Don't assume that's acceptable; flag it as an explicit decision, especially for regulated domains.
## 3. Local models move server-side
If the OSS config used specific local/self-chosen models (e.g. Ollama, a particular embedder, a
non-OpenAI LLM for fact extraction), those choices disappear — extraction and embedding now run on
the platform with the platform's configuration. Memory *content and quality may shift* as a result.
Flag where the project depended on a specific model.
## 4. Graph memory
If the project uses graph memory (`enable_graph`, `graph_store`), this changed in v3 and is handled
differently on the platform. Don't assume a drop-in mapping — verify current platform graph support
in the docs and flag the usage for the developer.
## 5. Custom prompts / extraction config
`custom_fact_extraction_prompt` → `custom_instructions`, and `custom_update_memory_prompt` is
deprecated. On the platform these tend to be **project-level settings configured in the dashboard**
rather than passed in code. Flag any custom prompt the project relied on so the developer can re-
apply it in the dashboard.
## 6. Every call is now a network request
Local calls become remote API calls. That introduces latency, network failures, timeouts, rate
limits, and per-call cost. Flag mem0 calls on hot paths or in tight loops, and recommend adding
error handling / retries / timeouts where the old local calls were effectively infallible. For async
apps, use `AsyncMemoryClient` (Python) so calls don't block the event loop.
## 7. API key & secrets
The hosted client needs `MEM0_API_KEY`. It must come from the environment / a secrets manager — never
hardcoded. Ensure it's added to `.env.example`, local `.env`, CI, and deployment config. Without it
the client fails to initialize.
## 8. Dropped constructor args & legacy options
`org_id` / `project_id` (Python) and `organizationId` / `projectId` (TS) are no longer passed to the
constructor in v3 — they're resolved from the API key. Per-call legacy options like `async_mode`,
`output_format`, and `enable_graph` are gone. Remove them rather than leaving dead args.
## 9. Return-shape drift
- `add()` returns **only ADD events** on v3. Code that inspected `add()` results for `UPDATE` /
`DELETE` events has dead branches now.
- `search` / `get_all` return `{"results": [...]}`; `get_all` is paginated (`count`/`next`/
`previous`/`results`). Code that limited via `top_k` on `get_all` should move to `page`/`page_size`.
- Default `top_k` dropped 100 → 20, `threshold` now `0.1`, `rerank` now `false` — result counts and
ordering can change even when the call looks equivalent.
## 10. No global `reset()`
The OSS `reset()` wipes the whole local store. There's no hosted equivalent that nukes everything;
use `delete_all` scoped by `filters`. Flag any `reset()` call.
@@ -1,72 +0,0 @@
# Plan template — `MEM0_MIGRATION_PLAN.md`
Write the plan to `MEM0_MIGRATION_PLAN.md` at the repo root using the structure below. Keep it
concrete enough to execute from and honest about the gaps. Fill every section from the actual
findings — don't leave placeholders. Drop a section only if it genuinely doesn't apply (and say so).
```markdown
# mem0 OSS → Platform Migration Plan
## Summary
- One paragraph: what's moving (self-hosted `Memory` → hosted `MemoryClient`) and why.
- Detected language(s) and the installed `mem0ai` version.
- Counts: N files touched, M call sites, plus config/deps/env changes.
## Prerequisites
- `MEM0_API_KEY` must be set in the environment before execution/verification (https://app.mem0.ai).
- Note where it should live (`.env`, secrets manager, CI, deploy config).
## Inventory
A table of every mem0 touchpoint found, so the developer can see the full footprint:
| File:line | Current (OSS) usage | Category | Maps to |
|-----------|--------------------|----------|---------|
| path:LN | `Memory.from_config({...})` | client init | `MemoryClient()` |
| path:LN | `memory.search(q, user_id=...)` | call site | `search(q, filters={...}, top_k=...)` |
| ... | ... | config / dep / env / infra | ... |
## Change set
Grouped by file. For each, a short before → after with the actual surrounding code, so the edits are
unambiguous. Example:
### `path/to/file.py`
- Replace import `from mem0 import Memory` → `from mem0 import MemoryClient`.
- Replace the `Memory.from_config({...})` block with `MemoryClient()` (drops local vector_store/
llm/embedder/history config).
- `search(...)`: move `user_id` into `filters={"user_id": ...}`; keep `top_k`.
```
# before
...
# after
...
```
## Dependencies & config
- `requirements.txt` / `pyproject.toml` / `package.json`: keep `mem0ai`; remove now-unused local-
infra deps (list them, with the reason each is safe to remove).
- Env: add `MEM0_API_KEY`; remove env vars only used by the old local embedder/LLM if now unused.
- Infrastructure: local services that existed only for mem0 (e.g. a Qdrant docker-compose service)
can be retired — listed for the developer's confirmation, not auto-deleted.
## Concerns & decisions needed
The non-1:1 items from the gotchas that apply here, each phrased as a decision for the developer.
Cover, where relevant: data not migrating, self-hosting/data-residency, local models moving server-
side, graph memory, custom prompts (now dashboard settings), network/latency/cost on hot paths,
return-shape changes (`add` ADD-only, `get_all` pagination, default `top_k`/threshold/rerank), and
any `reset()` usage. Be specific about which file/line each concern affects.
## Out of scope
- Existing memory **data** is not migrated (code only). If wanted, it's a separate opt-in task
(export from the OSS store, re-add to the hosted client).
- No unrelated refactors, renames, or behavior changes.
## Verification plan
How execution will be confirmed end-to-end:
- Imports / type-checks / byte-compiles cleanly.
- Smoke test against the hosted API with a real `MEM0_API_KEY`: `add` a fact → `search`/`get_all`
returns it → `delete_all` clears it. Plus: the app's own entry point still runs.
- Confirm no local mem0 storage dir is created anymore (e.g. `.mem0/`) — proof memory is hosted.
## Rollback
- All changes are in version control; revert with git if needed. Note the branch/commit strategy.
```
-60
View File
@@ -1,60 +0,0 @@
import pytest
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
def test_sync_project_update_decay_true_raises_with_notice_message(monkeypatch):
calls = []
def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked decay"
monkeypatch.setattr(memory_main, "get_decay_feature_error_message", get_error)
with pytest.raises(ValueError, match="blocked decay"):
Memory.__new__(Memory).project.update(decay=True)
assert calls == [("sync", "project.update", "decay")]
@pytest.mark.asyncio
async def test_async_project_update_decay_true_raises_with_notice_message(monkeypatch):
calls = []
async def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked async decay"
monkeypatch.setattr(memory_main, "get_decay_feature_error_message_async", get_error)
with pytest.raises(ValueError, match="blocked async decay"):
await AsyncMemory.__new__(AsyncMemory).project.update(decay=True)
assert calls == [("async", "project.update", "decay")]
@pytest.mark.parametrize("kwargs", [{}, {"decay": False}])
def test_sync_project_update_non_trigger_raises_plain_error_without_notice(monkeypatch, kwargs):
monkeypatch.setattr(
memory_main,
"get_decay_feature_error_message",
lambda *args: pytest.fail("decay feature notice should not run"),
)
with pytest.raises(ValueError, match="Project updates are not supported by the OSS Memory SDK."):
Memory.__new__(Memory).project.update(**kwargs)
@pytest.mark.asyncio
@pytest.mark.parametrize("kwargs", [{}, {"decay": False}])
async def test_async_project_update_non_trigger_raises_plain_error_without_notice(monkeypatch, kwargs):
monkeypatch.setattr(
memory_main,
"get_decay_feature_error_message_async",
lambda *args: pytest.fail("decay feature notice should not run"),
)
with pytest.raises(ValueError, match="Project updates are not supported by the OSS Memory SDK."):
await AsyncMemory.__new__(AsyncMemory).project.update(**kwargs)
-214
View File
@@ -1,214 +0,0 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
def make_sync_memory():
memory = Memory.__new__(Memory)
memory.vector_store = MagicMock()
memory._delete_memory = MagicMock()
return memory
def make_async_memory():
memory = AsyncMemory.__new__(AsyncMemory)
memory.vector_store = MagicMock()
memory._delete_memory = AsyncMock()
return memory
def test_sync_delete_decay_usage_runs_after_success(monkeypatch):
memory = make_sync_memory()
existing_memory = SimpleNamespace(id="memory-1")
memory.vector_store.get.return_value = existing_memory
decay_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(
memory_main,
"detect_decay_usage_from_delete",
MagicMock(return_value=("delete_count", "repeated_deletes", 5, None)),
)
monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
result = Memory.delete(memory, "memory-1")
assert result == {"message": "Memory deleted successfully!"}
memory._delete_memory.assert_called_once_with("memory-1", existing_memory)
decay_notice.assert_called_once_with(
memory,
"sync",
"delete",
"delete_count",
"repeated_deletes",
5,
None,
)
first_run_notice.assert_not_called()
def test_sync_delete_below_threshold_uses_first_run_notice(monkeypatch):
memory = make_sync_memory()
memory.vector_store.get.return_value = SimpleNamespace(id="memory-1")
decay_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", MagicMock(return_value=None))
monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.delete(memory, "memory-1")
decay_notice.assert_not_called()
first_run_notice.assert_called_once_with(memory, "sync", "delete")
def test_sync_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch):
memory = make_sync_memory()
memory.vector_store.get.return_value = None
detect_decay = MagicMock()
decay_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", detect_decay)
monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
with pytest.raises(ValueError, match="Memory with id memory-1 not found"):
Memory.delete(memory, "memory-1")
detect_decay.assert_not_called()
decay_notice.assert_not_called()
first_run_notice.assert_not_called()
def test_sync_delete_all_decay_usage_runs_after_success(monkeypatch):
memory = make_sync_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.return_value = (memories, None)
decay_notice = MagicMock()
first_run_notice = MagicMock()
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", detect_decay)
monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
result = Memory.delete_all(memory, user_id="u1")
assert result == {"message": "Memories deleted successfully!"}
assert memory._delete_memory.call_count == 2
detect_decay.assert_called_once_with(2)
decay_notice.assert_called_once_with(
memory,
"sync",
"delete_all",
"delete_all",
"bulk_delete",
None,
2,
)
first_run_notice.assert_not_called()
def test_sync_delete_all_zero_deletes_uses_first_run_notice(monkeypatch):
memory = make_sync_memory()
memory.vector_store.list.return_value = ([], None)
decay_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", MagicMock(return_value=None))
monkeypatch.setattr(memory_main, "display_decay_usage_notice", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.delete_all(memory, user_id="u1")
decay_notice.assert_not_called()
first_run_notice.assert_called_once_with(memory, "sync", "delete_all")
@pytest.mark.asyncio
async def test_async_delete_decay_usage_runs_after_success(monkeypatch):
memory = make_async_memory()
existing_memory = SimpleNamespace(id="memory-1")
memory.vector_store.get.return_value = existing_memory
decay_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(
memory_main,
"detect_decay_usage_from_delete",
MagicMock(return_value=("delete_count", "repeated_deletes", 5, None)),
)
monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.delete(memory, "memory-1")
assert result == {"message": "Memory deleted successfully!"}
memory._delete_memory.assert_awaited_once_with("memory-1", existing_memory)
decay_notice.assert_awaited_once_with(
memory,
"async",
"delete",
"delete_count",
"repeated_deletes",
5,
None,
)
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_delete_failure_does_not_trigger_decay_usage_notice(monkeypatch):
memory = make_async_memory()
memory.vector_store.get.return_value = None
detect_decay = MagicMock()
decay_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete", detect_decay)
monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
with pytest.raises(ValueError, match="Memory with id memory-1 not found"):
await AsyncMemory.delete(memory, "memory-1")
detect_decay.assert_not_called()
decay_notice.assert_not_awaited()
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_delete_all_decay_usage_runs_after_success(monkeypatch):
memory = make_async_memory()
memories = [SimpleNamespace(id="memory-1"), SimpleNamespace(id="memory-2")]
memory.vector_store.list.return_value = (memories, None)
decay_notice = AsyncMock()
first_run_notice = AsyncMock()
detect_decay = MagicMock(return_value=("delete_all", "bulk_delete", None, 2))
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "detect_decay_usage_from_delete_all", detect_decay)
monkeypatch.setattr(memory_main, "display_decay_usage_notice_async", decay_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.delete_all(memory, user_id="u1")
assert result == {"message": "Memories deleted successfully!"}
assert memory._delete_memory.await_count == 2
detect_decay.assert_called_once_with(2)
decay_notice.assert_awaited_once_with(
memory,
"async",
"delete_all",
"delete_all",
"bulk_delete",
None,
2,
)
first_run_notice.assert_not_awaited()
+2 -2
View File
@@ -828,7 +828,7 @@ class TestEntityBoostParallelism:
boosts = mock_memory._compute_entity_boosts(entities, {"user_id": "u1"})
elapsed = time.perf_counter() - start
assert elapsed < 0.75, f"searches did not run concurrently (took {elapsed:.2f}s)"
assert elapsed < 0.6, f"searches did not run concurrently (took {elapsed:.2f}s)"
assert concurrent_count["peak"] >= 2, "no overlap observed between entity searches"
assert len(boosts) == 4
@@ -854,6 +854,6 @@ class TestEntityBoostParallelism:
boosts = await mock_async_memory._compute_entity_boosts_async(entities, {"user_id": "u1"})
elapsed = time.perf_counter() - start
assert elapsed < 0.75, f"searches did not run concurrently (took {elapsed:.2f}s)"
assert elapsed < 0.6, f"searches did not run concurrently (took {elapsed:.2f}s)"
assert concurrent_count["peak"] >= 2, "no overlap observed between entity searches"
assert len(boosts) == 4
+1 -56
View File
@@ -1,60 +1,5 @@
import pytest
from unittest.mock import Mock
from mem0.memory.utils import parse_vision_messages, remove_spaces_from_entities, sanitize_relationship_for_cypher
class TestParseVisionMessages:
def test_multimodal_list_without_llm_extracts_text(self):
messages = [
{"role": "user", "content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
]},
]
result = parse_vision_messages(messages, llm=None)
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"] == "What is this?"
def test_image_dict_without_llm_is_skipped(self):
messages = [
{"role": "user", "content": {"type": "image_url", "image_url": {"url": "https://example.com/img.png"}}},
{"role": "user", "content": "hello"},
]
result = parse_vision_messages(messages, llm=None)
assert len(result) == 1
assert result[0]["content"] == "hello"
def test_multimodal_with_llm_calls_generate_response(self):
mock_llm = Mock()
mock_llm.generate_response.return_value = "A photo of a cat"
messages = [
{"role": "user", "content": [
{"type": "text", "text": "Describe this"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}},
]},
]
result = parse_vision_messages(messages, llm=mock_llm, vision_details="auto")
assert result[0]["content"] == "A photo of a cat"
mock_llm.generate_response.assert_called_once()
def test_image_only_list_without_llm_is_skipped(self):
messages = [
{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
]},
]
result = parse_vision_messages(messages, llm=None)
assert result == []
def test_plain_text_messages_pass_through(self):
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
result = parse_vision_messages(messages, llm=None)
assert result == messages
from mem0.memory.utils import remove_spaces_from_entities, sanitize_relationship_for_cypher
class TestRemoveSpacesFromEntities:
File diff suppressed because it is too large Load Diff
@@ -1,445 +0,0 @@
from copy import deepcopy
from unittest.mock import AsyncMock, MagicMock
import pytest
from mem0.memory import notices
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
class FakeFlags:
def __init__(self, variant, payload):
self.variant = variant
self.payload = payload
def get_flag(self, key):
assert key == notices.FLAG_KEY
return self.variant
def get_flag_payload(self, key):
assert key == notices.FLAG_KEY
return self.payload
@pytest.fixture(autouse=True)
def reset_notice_process_state():
notices._performance_slow_query_capacity_reached_in_process = False
yield
notices._performance_slow_query_capacity_reached_in_process = False
@pytest.fixture
def notice_harness(monkeypatch):
config = {}
telemetry = MagicMock()
telemetry.user_id = "oss-user"
def write_config(updated):
saved = deepcopy(updated)
config.clear()
config.update(saved)
monkeypatch.setattr(notices, "_load_config", lambda: config)
monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
return config, telemetry
def configure_flag(telemetry, variant, payload):
flags = FakeFlags(variant, payload)
telemetry.posthog.evaluate_flags.return_value = flags
return flags
def performance_payload(copy="Performance CTA", enabled=True, notice_type="log_line"):
payload = {
"notices": {
"performance_slow_query": {
"enabled": enabled,
"notice_type": notice_type,
}
}
}
if copy is not None:
payload["notices"]["performance_slow_query"]["copy"] = copy
return payload
def make_sync_memory(search_results=None):
memory = Memory.__new__(Memory)
memory.api_version = "v1.1"
memory.reranker = None
memory._search_vector_store = MagicMock(return_value=search_results or [])
return memory
def make_async_memory(search_results=None):
memory = AsyncMemory.__new__(AsyncMemory)
memory.api_version = "v1.1"
memory.reranker = None
memory._search_vector_store = AsyncMock(return_value=search_results or [])
return memory
def test_sync_slow_search_triggers_performance_notice_after_success(monkeypatch):
results = [{"id": "m1"}, {"id": "m2"}]
memory = make_sync_memory(search_results=results)
performance_notice = MagicMock()
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
result = Memory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=3)
assert result == {"results": results}
memory._search_vector_store.assert_called_once()
performance_notice.assert_called_once_with(memory, "sync", "search", pytest.approx(2.1), 3, 2)
temporal_notice.assert_not_called()
first_run_notice.assert_not_called()
def test_sync_fast_search_uses_first_run_notice(monkeypatch):
memory = make_sync_memory()
performance_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 101.0]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", MagicMock())
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.search(memory, "favorite drink", filters={"user_id": "u1"})
performance_notice.assert_not_called()
first_run_notice.assert_called_once_with(memory, "sync", "search")
def test_sync_failed_search_does_not_trigger_performance_notice(monkeypatch):
memory = make_sync_memory()
memory._search_vector_store.side_effect = RuntimeError("search failure")
performance_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(return_value=100.0))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", MagicMock())
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
with pytest.raises(RuntimeError, match="search failure"):
Memory.search(memory, "favorite drink", filters={"user_id": "u1"})
performance_notice.assert_not_called()
first_run_notice.assert_not_called()
def test_sync_temporal_usage_takes_precedence_over_slow_search(monkeypatch):
memory = make_sync_memory()
performance_notice = MagicMock()
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.search(memory, "what happened last week?", filters={"user_id": "u1"})
temporal_notice.assert_called_once_with(memory, "sync", "search", "query", "relative_phrase")
performance_notice.assert_not_called()
first_run_notice.assert_not_called()
def test_sync_scale_takes_precedence_over_slow_search(monkeypatch):
memory = make_sync_memory()
performance_notice = MagicMock()
scale_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice", performance_notice)
monkeypatch.setattr(memory_main, "display_scale_threshold_notice", scale_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=50)
scale_notice.assert_called_once_with(
memory,
"sync",
"search",
"top_k",
"high_top_k",
50,
None,
notices.SCALE_TOP_K_THRESHOLD,
)
performance_notice.assert_not_called()
first_run_notice.assert_not_called()
@pytest.mark.asyncio
async def test_async_slow_search_triggers_performance_notice_after_success(monkeypatch):
results = [{"id": "m1"}]
memory = make_async_memory(search_results=results)
performance_notice = AsyncMock()
temporal_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"}, top_k=4)
assert result == {"results": results}
memory._search_vector_store.assert_awaited_once()
performance_notice.assert_awaited_once_with(memory, "async", "search", pytest.approx(2.1), 4, 1)
temporal_notice.assert_not_awaited()
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_fast_search_uses_first_run_notice(monkeypatch):
memory = make_async_memory()
performance_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 101.0]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", AsyncMock())
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"})
performance_notice.assert_not_awaited()
first_run_notice.assert_awaited_once_with(memory, "async", "search")
@pytest.mark.asyncio
async def test_async_failed_search_does_not_trigger_performance_notice(monkeypatch):
memory = make_async_memory()
memory._search_vector_store.side_effect = RuntimeError("search failure")
performance_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(return_value=100.0))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", AsyncMock())
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
with pytest.raises(RuntimeError, match="search failure"):
await AsyncMemory.search(memory, "favorite drink", filters={"user_id": "u1"})
performance_notice.assert_not_awaited()
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_temporal_usage_takes_precedence_over_slow_search(monkeypatch):
memory = make_async_memory()
performance_notice = AsyncMock()
temporal_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main.time, "perf_counter", MagicMock(side_effect=[100.0, 102.1]))
monkeypatch.setattr(memory_main, "display_performance_slow_query_notice_async", performance_notice)
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
await AsyncMemory.search(memory, "what happened last week?", filters={"user_id": "u1"})
temporal_notice.assert_awaited_once_with(memory, "async", "search", "query", "relative_phrase")
performance_notice.assert_not_awaited()
first_run_notice.assert_not_awaited()
def test_performance_slow_query_displayed_logs_and_captures_event(notice_harness, capsys):
config, telemetry = notice_harness
flags = configure_flag(telemetry, "displayed", performance_payload())
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.345,
top_k=20,
result_count=7,
)
assert capsys.readouterr().err == "Performance CTA\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY])
telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT
assert props["notice_id"] == "performance_slow_query"
assert props["notice_type"] == "log_line"
assert props["variant"] == "displayed"
assert props["displayed"] is True
assert props["payload"] == "Performance CTA"
assert props["bypass_reason"] is None
assert props["disabled_reason"] is None
assert props["notice_config_found"] is True
assert props["sync_type"] == "sync"
assert props["trigger_function"] == "search"
assert props["trigger_reason"] == "slow_query"
assert props["elapsed_ms"] == 2345
assert props["threshold_ms"] == 2000
assert props["top_k"] == 20
assert props["result_count"] == 7
assert telemetry.capture_event.call_args.kwargs["flags"] is flags
assert len(config["notice_state"]["performance_slow_query"]["events"]) == 1
def test_performance_slow_query_holdout_is_silent_but_captures_event(notice_harness, capsys):
_, telemetry = notice_harness
configure_flag(telemetry, "holdout", performance_payload())
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
assert capsys.readouterr().err == ""
props = telemetry.capture_event.call_args.args[1]
assert props["displayed"] is False
assert props["bypass_reason"] == "holdout"
assert props["trigger_reason"] == "slow_query"
@pytest.mark.parametrize(
("payload", "expected_reason", "expected_found"),
[
({}, "missing_notice_config", False),
({"notices": {}}, "missing_notice_config", False),
({"notices": "not-an-object"}, "missing_notice_config", False),
(performance_payload(copy=None), "missing_copy", True),
(performance_payload(enabled=False, copy="hidden"), "payload_disabled", True),
],
)
def test_performance_slow_query_bad_or_disabled_payload_is_silent_and_safe(
notice_harness, payload, expected_reason, expected_found, capsys
):
_, telemetry = notice_harness
configure_flag(telemetry, "displayed", payload)
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
assert capsys.readouterr().err == ""
props = telemetry.capture_event.call_args.args[1]
assert props["displayed"] is False
assert props["bypass_reason"] == expected_reason
assert props["notice_config_found"] is expected_found
@pytest.mark.parametrize("variant", [None, False])
def test_performance_slow_query_blunt_flag_disable_does_not_capture_or_consume(
notice_harness, variant, capsys
):
config, telemetry = notice_harness
configure_flag(telemetry, variant, performance_payload())
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
assert capsys.readouterr().err == ""
telemetry.capture_event.assert_not_called()
assert config.get("notice_state") is None
def test_performance_slow_query_telemetry_disabled_does_not_touch_posthog_or_state(
monkeypatch, capsys
):
load_config = MagicMock(return_value={})
write_config = MagicMock()
get_telemetry = MagicMock()
monkeypatch.setattr(notices, "_load_config", load_config)
monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", False)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", get_telemetry)
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
load_config.assert_not_called()
write_config.assert_not_called()
get_telemetry.assert_not_called()
assert capsys.readouterr().err == ""
def test_performance_slow_query_cap_blocks_before_posthog_eval(notice_harness, capsys):
config, telemetry = notice_harness
configure_flag(telemetry, "displayed", performance_payload())
for _ in range(notices.PERFORMANCE_SLOW_QUERY_CAP):
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP
def test_performance_slow_query_props_do_not_include_raw_user_inputs(notice_harness):
_, telemetry = notice_harness
configure_flag(telemetry, "displayed", performance_payload(copy="safe copy"))
notices.display_performance_slow_query_notice(
MagicMock(),
"sync",
"search",
elapsed_seconds=2.1,
top_k=10,
result_count=2,
)
props = telemetry.capture_event.call_args.args[1]
assert "favorite drink" not in str(props)
assert "user_id" not in str(props)
assert "green tea" not in str(props)
@@ -1,96 +0,0 @@
import pytest
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
def test_sync_add_timestamp_raises_before_validation(monkeypatch):
calls = []
def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked timestamp"
monkeypatch.setattr(memory_main, "get_temporal_feature_error_message", get_error)
with pytest.raises(ValueError, match="blocked timestamp"):
Memory.add(Memory.__new__(Memory), "hello", timestamp=123)
assert calls == [("sync", "add", "timestamp")]
def test_sync_search_reference_date_raises_before_validation(monkeypatch):
calls = []
def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked reference date"
monkeypatch.setattr(memory_main, "get_temporal_feature_error_message", get_error)
with pytest.raises(ValueError, match="blocked reference date"):
Memory.search(Memory.__new__(Memory), "what happened last week?", reference_date="2025-03-21")
assert calls == [("sync", "search", "reference_date")]
@pytest.mark.asyncio
async def test_async_add_timestamp_raises_before_validation(monkeypatch):
calls = []
async def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked async timestamp"
monkeypatch.setattr(memory_main, "get_temporal_feature_error_message_async", get_error)
with pytest.raises(ValueError, match="blocked async timestamp"):
await AsyncMemory.add(AsyncMemory.__new__(AsyncMemory), "hello", timestamp=123)
assert calls == [("async", "add", "timestamp")]
@pytest.mark.asyncio
async def test_async_search_reference_date_raises_before_validation(monkeypatch):
calls = []
async def get_error(sync_type, trigger_function, trigger_parameter):
calls.append((sync_type, trigger_function, trigger_parameter))
return "blocked async reference date"
monkeypatch.setattr(memory_main, "get_temporal_feature_error_message_async", get_error)
with pytest.raises(ValueError, match="blocked async reference date"):
await AsyncMemory.search(
AsyncMemory.__new__(AsyncMemory),
"what happened last week?",
reference_date="2025-03-21",
)
assert calls == [("async", "search", "reference_date")]
def test_sync_add_without_timestamp_does_not_call_temporal_feature_notice(monkeypatch):
get_error = monkeypatch.setattr(
memory_main,
"get_temporal_feature_error_message",
lambda *args: pytest.fail("temporal feature notice should not run"),
)
with pytest.raises(Exception, match="At least one of 'user_id', 'agent_id', or 'run_id'"):
Memory.add(Memory.__new__(Memory), "hello")
assert get_error is None
def test_sync_search_without_reference_date_does_not_call_temporal_feature_notice(monkeypatch):
get_error = monkeypatch.setattr(
memory_main,
"get_temporal_feature_error_message",
lambda *args: pytest.fail("temporal feature notice should not run"),
)
with pytest.raises(ValueError, match="filters must contain"):
Memory.search(Memory.__new__(Memory), "hello")
assert get_error is None
-202
View File
@@ -1,202 +0,0 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from mem0.memory import main as memory_main
from mem0.memory.main import AsyncMemory, Memory
def make_sync_memory():
memory = Memory.__new__(Memory)
memory.config = SimpleNamespace(llm=SimpleNamespace(config={}))
memory.api_version = "v1.1"
memory.reranker = None
memory._add_to_vector_store = MagicMock(return_value=[])
memory._search_vector_store = MagicMock(return_value=[])
return memory
def make_async_memory():
memory = AsyncMemory.__new__(AsyncMemory)
memory.config = SimpleNamespace(llm=SimpleNamespace(config={}))
memory.api_version = "v1.1"
memory.reranker = None
memory._add_to_vector_store = AsyncMock(return_value=[])
memory._search_vector_store = AsyncMock(return_value=[])
return memory
def test_sync_add_temporal_metadata_triggers_notice_after_success(monkeypatch):
memory = make_sync_memory()
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
result = Memory.add(
memory,
"The user visited Paris.",
user_id="u1",
metadata={"event_date": "2025-04-09"},
infer=False,
)
assert result == {"results": []}
memory._add_to_vector_store.assert_called_once()
temporal_notice.assert_called_once_with(memory, "sync", "add", "metadata", "date_like_metadata")
first_run_notice.assert_not_called()
def test_sync_add_non_temporal_metadata_uses_first_run_notice(monkeypatch):
memory = make_sync_memory()
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
Memory.add(memory, "The user likes tea.", user_id="u1", metadata={"topic": "drink"}, infer=False)
temporal_notice.assert_not_called()
first_run_notice.assert_called_once_with(memory, "sync", "add")
def test_sync_add_failure_does_not_trigger_temporal_usage_notice(monkeypatch):
memory = make_sync_memory()
memory._add_to_vector_store.side_effect = RuntimeError("vector failure")
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
with pytest.raises(RuntimeError, match="vector failure"):
Memory.add(
memory,
"The user visited Paris.",
user_id="u1",
metadata={"event_date": "2025-04-09"},
infer=False,
)
temporal_notice.assert_not_called()
first_run_notice.assert_not_called()
def test_sync_search_temporal_query_triggers_notice_after_success(monkeypatch):
memory = make_sync_memory()
temporal_notice = MagicMock()
first_run_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", first_run_notice)
result = Memory.search(memory, "what happened last week?", filters={"user_id": "u1"})
assert result == {"results": []}
memory._search_vector_store.assert_called_once()
temporal_notice.assert_called_once_with(memory, "sync", "search", "query", "relative_phrase")
first_run_notice.assert_not_called()
def test_sync_search_temporal_filter_triggers_notice_after_success(monkeypatch):
memory = make_sync_memory()
temporal_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock())
Memory.search(
memory,
"favorite drink",
filters={"user_id": "u1", "created_at": {"gte": "2025-04-01"}},
)
temporal_notice.assert_called_once_with(memory, "sync", "search", "filter", "date_range_filter")
def test_sync_search_failure_does_not_trigger_temporal_usage_notice(monkeypatch):
memory = make_sync_memory()
memory._search_vector_store.side_effect = RuntimeError("search failure")
temporal_notice = MagicMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "display_temporal_usage_notice", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice", MagicMock())
with pytest.raises(RuntimeError, match="search failure"):
Memory.search(memory, "what happened last week?", filters={"user_id": "u1"})
temporal_notice.assert_not_called()
@pytest.mark.asyncio
async def test_async_add_temporal_metadata_triggers_notice_after_success(monkeypatch):
memory = make_async_memory()
temporal_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.add(
memory,
"The user visited Paris.",
user_id="u1",
metadata={"event_date": "2025-04-09"},
infer=False,
)
assert result == {"results": []}
memory._add_to_vector_store.assert_awaited_once()
temporal_notice.assert_awaited_once_with(memory, "async", "add", "metadata", "date_like_metadata")
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_add_runs_scale_detection_in_thread(monkeypatch):
memory = make_async_memory()
scale_detector = MagicMock(return_value=("memory_count", "memory_count_threshold", None, 2000, 2000))
scale_notice = AsyncMock()
first_run_notice = AsyncMock()
to_thread_calls = []
async def to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return fn(*args, **kwargs)
monkeypatch.setattr(memory_main, "detect_scale_threshold_from_add_result", scale_detector)
monkeypatch.setattr(memory_main.asyncio, "to_thread", to_thread)
monkeypatch.setattr(memory_main, "display_scale_threshold_notice_async", scale_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.add(memory, "The user likes tea.", user_id="u1", infer=False)
assert result == {"results": []}
assert to_thread_calls == [(scale_detector, (memory, []), {})]
scale_detector.assert_called_once_with(memory, [])
scale_notice.assert_awaited_once_with(
memory,
"async",
"add",
"memory_count",
"memory_count_threshold",
None,
2000,
2000,
)
first_run_notice.assert_not_awaited()
@pytest.mark.asyncio
async def test_async_search_temporal_query_triggers_notice_after_success(monkeypatch):
memory = make_async_memory()
temporal_notice = AsyncMock()
first_run_notice = AsyncMock()
monkeypatch.setattr(memory_main, "capture_event", MagicMock())
monkeypatch.setattr(memory_main, "display_temporal_usage_notice_async", temporal_notice)
monkeypatch.setattr(memory_main, "display_first_run_notice_async", first_run_notice)
result = await AsyncMemory.search(memory, "what happened last week?", filters={"user_id": "u1"})
assert result == {"results": []}
memory._search_vector_store.assert_awaited_once()
temporal_notice.assert_awaited_once_with(memory, "async", "search", "query", "relative_phrase")
first_run_notice.assert_not_awaited()
-20
View File
@@ -70,10 +70,6 @@ class TestTelemetryEnabled:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
mock_posthog.assert_called_once()
assert (
mock_posthog.call_args.kwargs["feature_flags_request_timeout_seconds"]
== telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS
)
assert at.posthog is not None
assert at.user_id == "test-user"
@@ -88,22 +84,6 @@ class TestTelemetryEnabled:
telemetry_module.capture_event("test.event", mock_memory)
mock_at.capture_event.assert_called_once()
def test_anonymous_capture_event_passes_flags_to_posthog(self):
"""capture_event() should use PostHog's event-first API and preserve flag snapshots."""
flags = MagicMock()
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="test-user"):
at = telemetry_module.AnonymousTelemetry()
at.capture_event("test.event", {"key": "value"}, flags=flags)
mock_posthog_cls.return_value.capture.assert_called_once()
args, kwargs = mock_posthog_cls.return_value.capture.call_args
assert args == ("test.event",)
assert kwargs["distinct_id"] == "test-user"
assert kwargs["flags"] is flags
assert kwargs["properties"]["key"] == "value"
def test_capture_client_event_sends_when_enabled(self):
"""capture_client_event() should call client_telemetry.capture_event when enabled."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
+6 -12
View File
@@ -162,22 +162,16 @@ class TestBeforeSendWiring:
# before_send is None (default), not _sampling_before_send
assert kwargs.get("before_send") is None
def test_anonymous_telemetry_constructs_posthog_with_current_kwargs(self):
"""AnonymousTelemetry uses the supported PostHog constructor shape."""
def test_anonymous_telemetry_falls_back_when_posthog_rejects_before_send(self):
"""If posthog (older version) rejects before_send, construction still succeeds."""
with patch.object(telemetry_module, "MEM0_TELEMETRY", True):
with patch("mem0.memory.telemetry.Posthog") as mock_posthog_cls:
with patch("mem0.memory.telemetry.get_or_create_user_id", return_value="u"):
# First call (with before_send) raises TypeError; second call succeeds.
mock_posthog_cls.side_effect = [TypeError("unexpected kwarg before_send"), object()]
at = telemetry_module.AnonymousTelemetry(before_send=telemetry_module._sampling_before_send)
mock_posthog_cls.assert_called_once()
_, kwargs = mock_posthog_cls.call_args
assert kwargs["project_api_key"] == telemetry_module.PROJECT_API_KEY
assert kwargs["host"] == telemetry_module.HOST
assert kwargs["before_send"] is telemetry_module._sampling_before_send
assert (
kwargs["feature_flags_request_timeout_seconds"]
== telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS
)
assert at.user_id == "u"
# Constructor was called twice: once with before_send, once without
assert mock_posthog_cls.call_count == 2
assert at.posthog is not None
-37
View File
@@ -1,37 +0,0 @@
from unittest.mock import MagicMock
from mem0.configs.vector_stores.qdrant import QdrantConfig
from mem0.vector_stores import qdrant as qdrant_module
def test_qdrant_config_accepts_explicit_https_false():
config = QdrantConfig(
host="127.0.0.1",
port=6333,
api_key="test-key",
https=False,
)
assert config.https is False
def test_qdrant_passes_explicit_https_to_client(monkeypatch):
client_cls = MagicMock()
monkeypatch.setattr(qdrant_module, "QdrantClient", client_cls)
monkeypatch.setattr(qdrant_module.Qdrant, "create_col", lambda *args, **kwargs: None)
qdrant_module.Qdrant(
collection_name="memories",
embedding_model_dims=1536,
host="127.0.0.1",
port=6333,
api_key="test-key",
https=False,
)
client_cls.assert_called_once_with(
api_key="test-key",
host="127.0.0.1",
port=6333,
https=False,
)