Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 884e056853 | |||
| fbf6b8c0db |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -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:**
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Generated
+144
-7
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user