Compare commits

..
Author SHA1 Message Date
kartik-mem0 fe20b2ea8a Merge remote-tracking branch 'origin/main' into feat/zapier-mem0 2026-07-29 19:59:58 +05:30
kartik-mem0 2e4f11afe1 chore(zapier): align package with @mem0 scope and repo conventions
Rename to @mem0/zapier, switch to Apache-2.0 to match the repo and the
other integrations, drop private so it can publish, and add the pnpm
security overrides plus pnpm-workspace.yaml that every other integration
under integrations/ already carries.
2026-07-29 16:53:06 +05:30
KartikandGitHub ea2ee07586 chore: remove OpenMemory from the monorepo (#6530) 2026-07-29 15:10:32 +05:30
KartikandGitHub 540d23d610 docs: serve an unconditioned favicon at the docs domain root (#6649) 2026-07-29 00:07:35 -07:00
KartikandGitHub 3274390f82 docs: redirect /integrations/keywords to /integrations/respan (#6648) 2026-07-28 23:57:37 -07:00
KartikandGitHub b357a5a1b0 chore(release): Python SDK v2.0.14, TypeScript SDK v3.1.2 (#6589) 2026-07-25 17:51:48 +05:30
Hrushikesh YadavandGitHub d653b63fac fix(milvus): guard text field in update() with _has_bm25_schema check (#5705) 2026-07-24 18:27:54 +05:30
Abhay SinghandGitHub cc4671579f fix(ts-oss/cassandra): apply every operator in a compound field filter (#6511) 2026-07-24 18:26:10 +05:30
01afdde3e7 salvage: fix(opensearch) re-raise search errors (credit @yashwanth123 #6477) (#6519)
Co-authored-by: yashwanth123 <yashwanth123@users.noreply.github.com>
2026-07-24 18:19:26 +05:30
d6d89c987b Add Oracle Vector Store Integration (#5358)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-07-23 21:30:01 +05:30
KartikandGitHub c2150e8f1a docs: SEO and AEO updates for the memory expiration page (#6535) 2026-07-23 20:46:29 +05:30
microblueyandGitHub 19c7bb84a2 fix(ts-oss/chroma): stop dropping filter conditions in where-clause translation (#6521) 2026-07-23 20:07:19 +05:30
Abhishek ChauhanandGitHub e6281ab724 fix(ts-oss): forward responseFormat to Gemini in generateResponse (#6468) 2026-07-23 19:48:33 +05:30
Rod BoevandGitHub a71d7bdbe3 fix(dashboard): clear the LLM API key on provider change (#6475) 2026-07-23 19:44:54 +05:30
microblueyandGitHub 56ec7d20f1 fix(vector_stores/opensearch): translate the '*' wildcard to an exists query for every key (#6522) 2026-07-23 19:41:09 +05:30
Himanshu-Sangshetti de39f294dd fix(zapier): address review feedback (masked key, ES imports) + test hygiene
- authentication: API key field is now type 'password' so Zapier masks it in
  the connection UI instead of showing it in cleartext.
- index: use ES imports for the platform version and package version instead of
  require(), matching the repo's TypeScript rule. dist stays flat; both versions
  resolve at runtime.
- AGENTS.md: add the Key Directories row for integrations/zapier-mem0.
- tests: disable telemetry during test runs so CI never fires real PostHog
  events, which also removes a lingering open handle after the suite.

Verified: build + zapier validate (0 failures) + 16 tests (13 unit, 3 live E2E).
2026-07-23 18:33:11 +05:30
Himanshu-Sangshetti 1bd9910382 fix(zapier): harden null-body handling and error messages
From an independent review pass:
- Guard search / get / add against a null or empty 2xx body (previously an
  uncaught TypeError on .results / .status); return [] or an empty object.
- Stringify non-string API error detail so a thrown message never renders as
  "[object Object]".
- Correct the Add sample to the default no-wait shape {event_id, status}.
- Add a regression unit test for the null-body path.

Verified: build + zapier validate + 16 tests (13 unit, 3 live E2E), plus a
live check of the v3-add to /v1/event poll cross-version path.
2026-07-23 11:49:25 +05:30
Himanshu-Sangshetti 9b1e2ef604 feat(integrations): add Zapier app for Mem0
A Zapier Platform CLI app (TypeScript) wrapping the hosted Mem0 v3 REST API:
Add / Search / Get Memories / Delete actions with custom API-key auth.

- Written in TypeScript, compiles to dist/; offline unit tests (mocked z.request)
  plus live E2E; `zapier validate` clean
- Add supports custom_instructions, custom_categories, includes, excludes;
  Wait-for-Completion defaults off (extraction can exceed Zapier's 30s step limit)
- Middleware surfaces non-2xx as errors (no silent empty results)
- Anonymous opt-out telemetry (MEM0_TELEMETRY=false)
- Docs page + docs.json/llms.txt entry, ci-gate wiring, AGENTS.md rows

Split out of #6057 per review (separate PRs per integration).
2026-07-23 03:05:53 +05:30
Abhay SinghandGitHub ca2abca2b8 fix(ts-oss/milvus): skip '*' wildcard filter values instead of matching literally (#6508) 2026-07-23 00:41:45 +05:30
Abhay SinghandGitHub 0e582adc6c fix(ts-oss/anthropic): find the text block in no-tools responses (#6506) 2026-07-23 00:40:40 +05:30
9caffeaa7b fix(ts-sdk): use textLemmatized for BM25 keyword search on Milvus, OpenSearch, and MongoDB (#6497)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-07-23 00:39:18 +05:30
a58e0586ad fix(vector_stores/opensearch): honor all filter keys instead of a hardcoded identity-key list (#6454)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
2026-07-23 00:24:16 +05:30
microblueyandGitHub a9cb4bb644 fix(vector_stores/chroma): stop dropping filter conditions in where-clause translation (#6452) 2026-07-23 00:22:10 +05:30
Gyubin SonandGitHub 7bf84b8d38 fix: drop unused vector column from pgvector get() and list() queries (#6483) 2026-07-23 00:11:40 +05:30
272 changed files with 7249 additions and 21976 deletions
-4
View File
@@ -17,10 +17,6 @@
"claude code", "opencode", "pi agent", "mem0-plugin",
"cursor plugin", "codex plugin", "editor plugin"
],
"openmemory": [
"openmemory", "open memory", "localhost:8765", "localhost:3000",
"openmemory ui", "openmemory/api", "openmemory/ui"
],
"cli": ["mem0-cli", "@mem0/cli", "npx mem0", "command line"],
"vector-store": [
"pgvector", "pinecone", "chroma", "chromadb", "weaviate",
-4
View File
@@ -23,10 +23,6 @@ rest-api:
- changed-files:
- any-glob-to-any-file: 'server/**'
openmemory:
- changed-files:
- any-glob-to-any-file: 'openmemory/**'
integrations:
- changed-files:
- any-glob-to-any-file: 'integrations/**'
@@ -35,12 +35,6 @@ const cases = [
body: "### 🐛 Describe the bug\n\nI'm using docker compose to deploy a REST API server. When adding memory, I'm unable to set the expiration_date. Is this feature not supported?",
expected: ["rest-api"],
},
{
number: 3444,
title: "Fix: Openmemory run.sh non-existent vector-store route",
body: "### 🐛 Describe the bug\n\n# Vector_store not implemented\nThere is many references to ` ${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store` in lines 280, 293, 306, 319, 332, 345, 358, and 371. \n```bash\ncurl -fsS -X PUT \"${NEXT_PUBLIC_API_URL}/api/v1/config/mem0/vector_store\" # Line 280 and for each vector store\n```\nBut the api route is not implemented in `api/app/routers/config.py`.\n# Suggested solution\nI would implement `vector_store` route or remove and use `update_configuration` for all config updates. Also Create class with all config keys for vector_store",
expected: ["openmemory"],
},
{
number: 6252,
title: "cursor: on_file_read_cursor.sh ignores auto_search / MEM0_AUTO_SEARCH",
+13
View File
@@ -40,6 +40,7 @@ jobs:
openclaw: ${{ steps.filter.outputs.openclaw }}
opencode_plugin: ${{ steps.filter.outputs.opencode_plugin }}
pi_agent_plugin: ${{ steps.filter.outputs.pi_agent_plugin }}
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
steps:
- uses: dorny/paths-filter@v3
@@ -79,6 +80,10 @@ jobs:
- 'integrations/pi-agent-plugin/**'
- '.github/workflows/pi-agent-plugin-checks.yml'
- '.github/workflows/ci-gate.yml'
zapier_mem0:
- 'integrations/zapier-mem0/**'
- '.github/workflows/zapier-mem0-checks.yml'
- '.github/workflows/ci-gate.yml'
docs_llms_txt:
- 'docs/**/*.mdx'
- 'docs/llms.txt'
@@ -136,6 +141,13 @@ jobs:
uses: ./.github/workflows/pi-agent-plugin-checks.yml
secrets: inherit
zapier-mem0:
name: Zapier App
needs: changes
if: needs.changes.outputs.zapier_mem0 == 'true'
uses: ./.github/workflows/zapier-mem0-checks.yml
secrets: inherit
docs-llms-txt:
name: docs llms.txt
needs: changes
@@ -154,6 +166,7 @@ jobs:
- openclaw
- opencode-plugin
- pi-agent-plugin
- zapier-mem0
- docs-llms-txt
if: always()
runs-on: ubuntu-latest
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
run: |
pip install --upgrade pip
pip install -e ".[test,graph,vector_stores,llms,extras]"
pip install ruff
pip install ruff==0.16.0
- name: Run Linting
if: needs.check_changes.outputs.mem0_changed == 'true'
run: make lint
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
script: |
const allowed = new Set([
'sdk-python', 'sdk-typescript', 'vector-store', 'plugin',
'rest-api', 'openmemory', 'documentation', 'ci', 'cli', 'integrations',
'rest-api', 'documentation', 'ci', 'cli', 'integrations',
]);
const umbrella = { plugin: 'integrations' };
const { repository } = await github.graphql(
+42
View File
@@ -0,0 +1,42 @@
name: Deploy zapier-mem0 to Zapier
# Zapier apps deploy to Zapier's own platform (not npm), so this is NOT wired
# into the npm release router (release.yml). It is manual workflow_dispatch
# only and requires the ZAPIER_DEPLOY_KEY repo secret.
#
# gh workflow run zapier-mem0-cd.yml --ref main
on:
workflow_dispatch:
jobs:
push:
name: Push zapier-mem0 to Zapier
runs-on: ubuntu-latest
defaults:
run:
working-directory: integrations/zapier-mem0
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build TypeScript
run: pnpm build
- name: Push to Zapier
env:
ZAPIER_DEPLOY_KEY: ${{ secrets.ZAPIER_DEPLOY_KEY }}
run: npx zapier-platform-cli@19 push
+47
View File
@@ -0,0 +1,47 @@
name: zapier-mem0 checks
# On PRs this is invoked by ci-gate.yml (the single required check);
# push-to-main and manual runs remain standalone.
#
# CI compiles the TypeScript app, runs `zapier validate` (offline schema + style
# checks) against the build, plus the offline jest unit suite (test/unit.test.ts —
# mocked z.request, no network). The end-to-end jest suite is skipped here because
# it hits the live Mem0 API — it runs locally with MEM0_API_KEY set (see README).
on:
workflow_dispatch:
push:
branches: [main]
paths:
- 'integrations/zapier-mem0/**'
- '.github/workflows/zapier-mem0-checks.yml'
workflow_call:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: integrations/zapier-mem0/pnpm-lock.yaml
- name: Install dependencies
run: cd integrations/zapier-mem0 && pnpm install --frozen-lockfile
- name: Build TypeScript
run: cd integrations/zapier-mem0 && pnpm build
- name: Validate Zapier app definition
run: cd integrations/zapier-mem0 && npx zapier-platform-cli@19 validate
- name: Run offline unit tests
run: cd integrations/zapier-mem0 && pnpm test:unit
+5 -28
View File
@@ -27,8 +27,8 @@ This is a **polyglot monorepo** containing Python and TypeScript packages, CLIs,
| `integrations/openclaw/` | `@mem0/openclaw-mem0` — OpenClaw plugin for Claude Code / AI editors |
| `integrations/pi-agent-plugin/` | `@mem0/pi-agent-plugin` — Pi Agent plugin |
| `integrations/vercel-ai-sdk/` | `@mem0/vercel-ai-provider` — Vercel AI SDK memory provider |
| `integrations/zapier-mem0/` | `zapier-mem0` — Zapier Platform CLI app (deploys to Zapier, not npm); add / search / get / delete memories |
| `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/` |
| `docs/` | Documentation site (Mintlify) |
| `tests/` | Python SDK tests (pytest) |
@@ -62,7 +62,7 @@ integrations/openclaw/ ──▶ mem0ai (npm)
- **Node.js**: v18+ (v20 or v22 recommended)
- **pnpm**: v10+ (`npm install -g pnpm@10`) — used for all TypeScript packages
- **Hatch**: Python build/environment tool (`pip install hatch`)
- **Docker**: Required for `server/` and `openmemory/` development
- **Docker**: Required for `server/` development
### Initial Setup
@@ -214,28 +214,6 @@ docker-compose up # starts all 3 services
- **Services:** PostgreSQL with pgvector, Neo4j 5.x with APOC plugin
- **Hot reload:** Dev Dockerfile mounts `server/` and `mem0/` for live changes
### OpenMemory (`openmemory/`)
```bash
# Full stack via Docker Compose
cd openmemory
docker-compose up
# Qdrant: localhost:6333
# API (MCP): localhost:8765
# UI: localhost:3000
# Individual development
cd openmemory/api && uvicorn main:app --reload # FastAPI backend
cd openmemory/ui && npm run dev # Next.js frontend
# Tests
cd openmemory/api && pytest tests/ # API tests (e.g., test_mcp_server.py)
```
- **API:** FastAPI + Alembic (DB migrations) + MCP server (Model Context Protocol)
- **UI:** Next.js 15, React 19, Radix UI, Redux Toolkit, TailwindCSS, Recharts
- **Vector store:** Qdrant
### Documentation (`docs/`)
```bash
@@ -331,7 +309,6 @@ python -m benchmarks.beam.run --project-name my-test --backend cloud --mem0-api-
- Root SDK: line length **120**
- Python CLI: line length **100** with extended rule set (UP, B, SIM, RUF)
- **isort** with `profile = "black"` for import sorting.
- Ruff excludes `openmemory/` from root config.
### TypeScript Conventions
@@ -382,7 +359,6 @@ Optional layer on top of vector memory for relationship-aware retrieval. Configu
Model Context Protocol support in multiple places:
- **Remote:** MCP server at `mcp.mem0.ai`
- **Local:** MCP server in `openmemory/api/` (FastAPI-based)
- **Plugin:** MCP tools in `integrations/mem0-plugin/` — 9 tools: `add_memory`, `search_memories`, `get_memories`, `get_memory`, `update_memory`, `delete_memory`, `delete_all_memories`, `delete_entities`, `list_entities`
### Plugin & Skills System
@@ -431,6 +407,7 @@ PR testing is orchestrated by a single entry point: **`ci-gate.yml` (CI Gate)**
| OpenClaw | `openclaw-checks.yml` | Push to main (on `integrations/openclaw/`), manual | tsc + vitest (with Codecov) + tsup build on Node 20, 22 |
| OpenCode Plugin | `opencode-plugin-checks.yml` | Push to main (on `integrations/mem0-plugin/.opencode-plugin/`), manual | Bun: tsc type-check + build + dist artifact check |
| Pi Agent Plugin | `pi-agent-plugin-checks.yml` | Push to main (on `integrations/pi-agent-plugin/`), manual | tsc + vitest + tsup build (dist artifact check) on Node 20, 22 |
| Zapier App | `zapier-mem0-checks.yml` | Push to main (on `integrations/zapier-mem0/`), manual | build (tsc) + `zapier validate` + offline unit tests on Node 22 |
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage check |
When adding a new package CI workflow: give it `workflow_call` (plus `push`/`workflow_dispatch` as needed, but no `pull_request` trigger), then register it in `ci-gate.yml` — a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
@@ -455,6 +432,7 @@ Publishing is routed through a single entry point: **`release.yml` (Release Rout
- All publishing uses **OIDC trusted publishing** — no tokens or secrets required.
- First publish of a new npm package must be done manually; OIDC works for subsequent versions.
- To re-publish a release (e.g. after a registry settings fix), do **not** delete/recreate the GitHub release — manually dispatch the package workflow instead: `gh workflow run <package>-cd.yml --ref refs/tags/<tag> -f tag=<tag>`.
- The **Zapier app** (`integrations/zapier-mem0`) deploys to Zapier's own platform, not npm, so it is **not** in the release router. Deploy it manually: `gh workflow run zapier-mem0-cd.yml --ref main` (requires the `ZAPIER_DEPLOY_KEY` secret).
- When adding a new package: add its CD workflow (`workflow_dispatch` with `tag`/`prerelease` inputs), then register its tag prefix in the `case` block in `release.yml`. Keep the bare `v*` arm last.
### Utility Workflows
@@ -585,7 +563,7 @@ N/A
- Follow existing code patterns — don't introduce new frameworks or abstractions without discussion.
- Version bumps go in `pyproject.toml` (Python) or `package.json` (TypeScript).
- For `server/` and `openmemory/` work, use Docker Compose for local development.
- For `server/` work, use Docker Compose for local development.
- Do NOT use `pip` or `conda` for dependency management — use `hatch` (see `docs/contributing/development.mdx`).
### Contributing Guides
@@ -608,5 +586,4 @@ N/A
- Use npm or yarn in TypeScript packages — this repo uses pnpm exclusively.
- Use `require()` for imports in TypeScript — use ES module `import` syntax.
- Mix up linter configs: root Python SDK uses line-length 120, Python CLI uses 100, Node CLI uses Biome (not ESLint/Ruff).
- Modify `openmemory/` database migrations without understanding the Alembic migration chain.
- Change public APIs without updating documentation in `docs/`.
+1 -1
View File
@@ -45,7 +45,7 @@ The two most common contribution targets are the SDKs:
| TypeScript SDK (`mem0ai`) | `mem0-ts/` | TypeScript | `pnpm` |
Other packages include the CLIs (`cli/python/`, `cli/node/`), integrations
(`integrations/`), the self-hosted `server/`, `openmemory/`, and the docs site
(`integrations/`), the self-hosted `server/`, and the docs site
(`docs/`). See [AGENTS.md](./AGENTS.md) for a full map of the repository.
## Development Workflow
+1 -1
View File
@@ -11,7 +11,7 @@ install:
hatch env create
install_all:
pip install ruff==0.6.9 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
pip install ruff==0.16.0 groq together boto3 litellm ollama chromadb weaviate weaviate-client sentence_transformers vertexai \
google-generativeai elasticsearch opensearch-py vecs "pinecone<7.0.0" pinecone-text faiss-cpu langchain-community \
upstash-vector azure-search-documents langchain-memgraph langchain-neo4j langchain-aws rank-bm25 pymochow pymongo psycopg kuzu databricks-sdk valkey
+1 -1
View File
@@ -21,7 +21,7 @@ privately through one of the following channels:
To help us triage and resolve the issue quickly, please include as much of the
following as you can:
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, OpenMemory)
- Affected component or package (e.g. Python SDK, TypeScript SDK, server, CLI)
- Affected version, tag, or commit
- Clear, step-by-step reproduction instructions
- The security impact and a proof of concept, if available
+24
View File
@@ -7,6 +7,18 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-07-25" description="v2.0.14">
**New Features:**
- **Vector Stores:** Add an Oracle AI Vector Search provider (`oracledb`) with connection pooling, `HNSW`/`IVF` indexes, JSON metadata filtering, and six selectable distance metrics ([#5358](https://github.com/mem0ai/mem0/pull/5358))
**Bug Fixes:**
- **Vector Stores:** Translate a `"*"` filter value in OpenSearch into an `exists` query for every key, not just identity keys. It was previously ignored or matched literally against the string `"*"`, so a wildcard filter returned nothing ([#6522](https://github.com/mem0ai/mem0/pull/6522))
- **Vector Stores:** Re-raise errors from OpenSearch `search()` instead of returning `[]`, so a transport, auth, or index misconfiguration surfaces instead of looking like zero matches. `keyword_search()` still degrades on failure, since it is a best-effort BM25 signal ([#6519](https://github.com/mem0ai/mem0/pull/6519))
- **Vector Stores:** Guard the `text` field in Milvus `update()` behind the `_has_bm25_schema` check, matching `insert()`, so updating a memory in a collection without the BM25 `text`/`sparse` schema no longer fails ([#5705](https://github.com/mem0ai/mem0/pull/5705))
</Update>
<Update label="2026-07-22" description="v2.0.13">
**Bug Fixes:**
@@ -1145,6 +1157,18 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-07-25" description="v3.1.2">
**Bug Fixes:**
- **Vector Stores:** Apply every operator in a Cassandra compound field filter (e.g. `{ age: { gte: 10, lte: 20 } }`) instead of stopping after the first, so the remaining bounds are no longer silently ignored ([#6511](https://github.com/mem0ai/mem0/pull/6511))
- **Vector Stores:** Stop the Chroma where-clause translator from dropping filter conditions. Same-field ranges (`gte` + `lte`), multi-field conditions inside `$or`, and negated `contains`/`icontains` under `$not` each collapsed to a single clause or vanished, widening the search instead of narrowing it ([#6521](https://github.com/mem0ai/mem0/pull/6521))
- **Vector Stores:** Skip `"*"` wildcard filter values in Milvus instead of matching them literally, so a filter like `{ user_id: "*" }` no longer returns zero memories ([#6508](https://github.com/mem0ai/mem0/pull/6508))
- **Vector Stores:** Read `textLemmatized` for BM25 keyword search on Milvus, OpenSearch, and MongoDB, matching the field the memory layer actually writes, so hybrid search on those backends no longer loses the keyword signal ([#6497](https://github.com/mem0ai/mem0/pull/6497))
- **LLMs:** Forward `responseFormat` to Gemini's `responseMimeType` in `generateResponse()`, so requesting `json_object` returns JSON instead of free-form text ([#6468](https://github.com/mem0ai/mem0/pull/6468))
- **LLMs:** Find the Anthropic text block by type instead of indexing `content[0]`, so a thinking-enabled model whose `thinking` block comes first no longer throws `Unexpected response type from Anthropic API` ([#6506](https://github.com/mem0ai/mem0/pull/6506))
</Update>
<Update label="2026-07-22" description="v3.1.1">
**New Features:**
+6 -1
View File
@@ -7,7 +7,7 @@ description: "Reference for vector database configuration options in Mem0, inclu
The `config` is defined as an object with two main keys:
- `vector_store`: Specifies the vector database provider and its configuration
- `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey")
- `provider`: The name of the vector database (e.g., "chroma", "pgvector", "qdrant", "milvus", "upstash_vector", "azure_ai_search", "vertex_ai_vector_search", "valkey", "oracledb")
- `config`: A nested dictionary containing provider-specific settings
@@ -95,6 +95,11 @@ Here's a comprehensive list of all parameters that can be used across different
| `connection_string` | PostgreSQL connection string (for Supabase/PGVector) |
| `index_method` | Vector index method (for Supabase) |
| `index_measure` | Distance measure for similarity search (for Supabase) |
| `connection_params` | Connection settings for Oracle AI Vector Search |
| `use_connection_pool` | Create an Oracle connection pool from `connection_params` |
| `distance_metric` | Distance metric for Oracle vector indexing and search |
| `index_type` | Oracle vector index type: `HNSW` or `IVF` |
| `index_parameters` | Oracle vector-index parameters for the selected index type |
</Tab>
<Tab title="TypeScript">
| Parameter | Description |
+134
View File
@@ -0,0 +1,134 @@
---
title: "Oracle AI Vector Search"
description: "Use Oracle Database AI Vector Search as a vector store in Mem0 for semantic and relational queries."
---
[Oracle AI Vector Search](https://www.oracle.com/database/ai-vector-search/) stores embeddings in an Oracle table using the native `VECTOR` data type, so you can combine semantic search over unstructured data with relational queries over business data in a single database.
### Requirements
- Oracle Database 23.4 or later, with a user that can create tables and vector indexes
- The `python-oracledb` driver. In thick mode, Oracle Client 23.4 or later is also required.
```bash
pip install oracledb
```
### Usage
<CodeGroup>
```python Python
import os
from mem0 import Memory
os.environ["OPENAI_API_KEY"] = "sk-xx"
config = {
"vector_store": {
"provider": "oracledb",
"config": {
"collection_name": "mem0",
"embedding_model_dims": 1536,
"connection_params": {
"user": "mem0_user",
"password": "your-password",
"dsn": "localhost:1521/FREEPDB1",
},
}
}
}
m = Memory.from_config(config)
messages = [
{"role": "user", "content": "I'm planning to watch a movie tonight. Any recommendations?"},
{"role": "assistant", "content": "How about thriller movies? They can be quite engaging."},
{"role": "user", "content": "I'm not a big fan of thriller movies but I love sci-fi movies."},
{"role": "assistant", "content": "Got it! I'll avoid thriller recommendations and suggest sci-fi movies in the future."}
]
m.add(messages, user_id="alice", metadata={"category": "movies"})
```
</CodeGroup>
To reuse a connection or pool you already manage, pass it as `client` instead of `connection_params`:
```python
import oracledb
pool = oracledb.create_pool(user="mem0_user", password="your-password", dsn="localhost:1521/FREEPDB1")
config = {
"vector_store": {
"provider": "oracledb",
"config": {"client": pool},
}
}
```
### Config
Here are the parameters available for configuring Oracle AI Vector Search:
| Parameter | Description | Default Value |
| --- | --- | --- |
| `connection_params` | Connection settings passed to `python-oracledb`, such as `user`, `password` and `dsn`. See the [connection handling guide](https://python-oracledb.readthedocs.io/en/latest/user_guide/connection_handling.html). | `None` |
| `use_connection_pool` | Create a connection pool from `connection_params` instead of a single connection | `True` |
| `client` | An existing `oracledb.Connection` or `oracledb.ConnectionPool` to use instead of building one from `connection_params` | `None` |
| `collection_name` | Name of the Oracle table that stores vectors and payloads | `mem0` |
| `embedding_model_dims` | Dimension of your embedding vectors, must be greater than 0 | `1536` |
| `distance_metric` | Distance function used for indexing and search: `COSINE`, `EUCLIDEAN`, `EUCLIDEAN_SQUARED`, `DOT`, `HAMMING` or `MANHATTAN` | `COSINE` |
| `do_create_index` | Whether to create a vector index on the collection | `True` |
| `index_type` | Vector index type: `HNSW` or `IVF` | `HNSW` |
| `index_name` | Name of the vector index | `<collection_name>_VEC_IDX` |
| `index_parameters` | Index tuning parameters. For `HNSW`: `neighbors`, `efconstruction`. For `IVF`: `neighbor partitions`, `samples_per_partition`, `min_vectors_per_partition`. | `None` |
| `index_accuracy` | Target index accuracy from 1 to 100, applied as `WITH TARGET ACCURACY <n>` | `None` |
<Note>
When you pass a pre-built `client`, Mem0 uses it as-is and ignores `connection_params` and `use_connection_pool`. Mem0 does not close a client it did not create.
</Note>
### Vector indexes
Set the index type with `index_type` and tune it with `index_parameters`:
```python
config = {
"vector_store": {
"provider": "oracledb",
"config": {
"connection_params": {"user": "mem0_user", "password": "your-password", "dsn": "localhost:1521/FREEPDB1"},
"index_type": "HNSW",
"index_parameters": {"neighbors": 32, "efconstruction": 200},
"index_accuracy": 95,
}
}
}
```
For the full list of supported options, see the Oracle [`CREATE VECTOR INDEX`](https://docs.oracle.com/en/database/oracle/oracle-database/26/sqlrf/create-vector-index.html) reference.
### Search scores
Oracle returns a distance from `VECTOR_DISTANCE`, which Mem0 converts to a `score` where higher means more similar. `COSINE` and the other non-negative metrics produce scores in the range `[0, 1]`. `DOT` returns the inner product, which can fall outside that range.
### Metadata filters
Filters run against the JSON `payload` column and support:
| Filter type | Examples |
| --- | --- |
| Scalar equality | `{"user_id": "alice"}` |
| Field existence | `{"agent_id": "*"}` |
| Comparison | `{"score": {"gte": 0.5}}`, also `eq`, `ne`, `gt`, `lt`, `lte` |
| Membership | `{"category": {"in": ["movies", "books"]}}`, also `nin` |
| String matching | `{"title": {"contains": "sci-fi"}}`, also `icontains` for case-insensitive |
| Logical groups | `{"AND": [...]}`, `{"OR": [...]}`, `{"NOT": [...]}` |
Multiple fields at the top level are combined with `AND`:
```python
m.search(
"movie recommendations",
user_id="alice",
filters={"category": {"in": ["movies", "books"]}, "rating": {"gte": 4}},
)
```
+2 -1
View File
@@ -1,6 +1,6 @@
---
title: Overview
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, and more."
description: "Overview of all supported vector databases in Mem0, including Qdrant, Chroma, PGVector, Pinecone, Oracle, and more."
---
Mem0 includes built-in support for various popular databases. Memory can utilize the database provided by the user, ensuring efficient use for specific needs.
@@ -21,6 +21,7 @@ See the list of supported vector databases below.
<Card title="Milvus" icon="/images/provider-icons/milvus.svg" href="/components/vectordbs/dbs/milvus"></Card>
<Card title="Pinecone" icon="/images/provider-icons/pinecone.svg" href="/components/vectordbs/dbs/pinecone"></Card>
<Card title="MongoDB" icon="/images/provider-icons/mongodb.svg" href="/components/vectordbs/dbs/mongodb"></Card>
<Card title="Oracle AI Vector Search" icon="/images/provider-icons/oracle.svg" href="/components/vectordbs/dbs/oracledb"></Card>
<Card title="Azure" icon="/images/provider-icons/azure-color.svg" href="/components/vectordbs/dbs/azure"></Card>
<Card title="Redis" icon="/images/provider-icons/redis.svg" href="/components/vectordbs/dbs/redis"></Card>
<Card title="Valkey" icon="/images/provider-icons/valkey.svg" href="/components/vectordbs/dbs/valkey"></Card>
+7 -1
View File
@@ -207,6 +207,7 @@
"components/vectordbs/dbs/milvus",
"components/vectordbs/dbs/pinecone",
"components/vectordbs/dbs/mongodb",
"components/vectordbs/dbs/oracledb",
"components/vectordbs/dbs/azure",
"components/vectordbs/dbs/azure_mysql",
"components/vectordbs/dbs/redis",
@@ -348,6 +349,7 @@
"pages": [
"integrations/dify",
"integrations/flowise",
"integrations/zapier",
"integrations/langchain-tools",
"integrations/agentops",
"integrations/respan",
@@ -633,7 +635,7 @@
},
{
"source": "/platform/features/expiration-date",
"destination": "/"
"destination": "/platform/features/memory-expiration"
},
{
"source": "/cookbooks/essentials/memory-expiration-short-and-long-term",
@@ -1234,6 +1236,10 @@
{
"source": "/platform/features/criteria-retrieval",
"destination": "/platform/features/advanced-retrieval"
},
{
"source": "/integrations/keywords",
"destination": "/integrations/respan"
}
]
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+1 -49
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="#8F74E0" role="img" viewBox="0 0 93.9 59.4" xmlns="http://www.w3.org/2000/svg"><title>Oracle</title><path d="M30.5,59.4H65c16.4-0.4,29.3-14.1,28.9-30.4C93.5,13.1,80.7,0.4,65,0H30.5C14.1-0.4,0.4,12.5,0,28.9s12.5,30,28.9,30.4C29.4,59.4,29.9,59.4,30.5,59.4 M64.2,48.9h-33c-10.6-0.3-18.9-9.2-18.6-19.8C13,19,21.1,10.8,31.2,10.5h33c10.6-0.3,19.5,8,19.8,18.6c0.3,10.6-8,19.5-18.6,19.8C65,48.9,64.6,48.9,64.2,48.9"/></svg>

After

Width:  |  Height:  |  Size: 427 B

+61
View File
@@ -0,0 +1,61 @@
---
title: Zapier
description: "Add, search, and manage Mem0 memories from any Zap using the Mem0 Zapier app, no code required."
---
The [Mem0](https://mem0.ai) Zapier app lets you add, search, list, and delete long-term memories from any [Zapier](https://zapier.com) workflow. Wire "remember" and "recall" into thousands of apps without writing code.
## Overview
The app wraps the hosted Mem0 REST API and exposes four actions:
| Type | Action | Endpoint |
| --- | --- | --- |
| Create | **Add Memory** | `POST /v3/memories/add/` |
| Create | **Delete Memory** | `DELETE /v1/memories/{id}/` |
| Search | **Search Memories** | `POST /v3/memories/search/` |
| Search | **Get Memories** | `POST /v3/memories/` |
## Authentication
The app uses API key authentication. Grab a key from the <a href="https://app.mem0.ai/dashboard/api-keys?utm_source=oss&utm_medium=integration-zapier" rel="nofollow">Mem0 API Key dashboard</a> and paste it when connecting your Mem0 account in Zapier. The key is sent as `Authorization: Token <key>` on every request.
## Actions
### Add Memory
Extracts and stores memories from a message. Fields:
- **Content** (required): the message text to extract memories from.
- **Role**: `user`, `assistant`, or `system`.
- **User ID / Agent ID / Run ID**: entity the memory belongs to.
- **Metadata (JSON)**: optional structured metadata.
- **Infer**: run LLM extraction (default) or store the message verbatim.
- **Wait for Completion**: off by default. Extraction is asynchronous, so the action returns immediately with an event ID. Turn this on to poll until extraction finishes and return the resulting memories. Extraction can take longer than a single Zapier step is allowed to run, so a timeout here does not mean the add failed; it usually still completes on the server.
### Search Memories
Semantic search over stored memories. Provide a **Query**, a **User ID** (required, the API needs an entity filter), and an optional **Limit**.
### Get Memories
Lists stored memories for a user. Provide a **User ID** (required), a **Limit** (page size), and a **Page** (1-based) to page through larger result sets.
### Delete Memory
Deletes a single memory by its **Memory ID**.
## Choosing a `userId`
The `user_id` is a stable string you choose to identify whose memories these are. It is not looked up in the dashboard. Common choices are your app's internal user ID, an email, or a UUID. Use the same value across Add, Search, and Get so recall works.
<CardGroup cols={2}>
<Card title="n8n Integration" icon="diagram-project" href="/integrations/n8n">
Build workflows with the Mem0 n8n community node
</Card>
<Card title="Flowise Integration" icon="blocks" href="/integrations/flowise">
Add memory to Flowise chatflows
</Card>
</CardGroup>
<Snippet file="star-on-github.mdx" />
+2 -1
View File
@@ -281,6 +281,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
### Developer Tools
- [Dify](https://docs.mem0.ai/integrations/dify) [Both]: Use when the user is on Dify LLMOps.
- [Flowise](https://docs.mem0.ai/integrations/flowise) [Both]: Use when the user is on Flowise no-code.
- [Zapier](https://docs.mem0.ai/integrations/zapier) [Both]: Use when the user automates workflows with Zapier.
- [AgentOps](https://docs.mem0.ai/integrations/agentops) [Both]: Use when tracking agent observability with memory metadata.
- [Respan](https://docs.mem0.ai/integrations/respan) [Both]: Use when monitoring Mem0 with Respan (formerly Keywords AI) LLM observability.
- [Raycast](https://docs.mem0.ai/integrations/raycast) [Both]: Use when the user wants quick memory access via Raycast.
@@ -416,7 +417,6 @@ Editor-specific setup docs (already listed above under `## Integrations > AI Cod
### MCP Endpoints
- Hosted MCP server: `https://mcp.mem0.ai` - requires Platform API key. See `platform/mem0-mcp`.
- Self-hosted MCP server: ships with `openmemory/api/` (FastAPI) - runs against your own Qdrant + LLM stack.
## Community & Support
@@ -472,6 +472,7 @@ Everything below is OSS-only provider configuration. Skip this entire section wh
- [Milvus](https://docs.mem0.ai/components/vectordbs/dbs/milvus) [OSS]: Use for large-scale Milvus deployments.
- [Pinecone](https://docs.mem0.ai/components/vectordbs/dbs/pinecone) [OSS]: Use when the user is on Pinecone managed.
- [MongoDB](https://docs.mem0.ai/components/vectordbs/dbs/mongodb) [OSS]: Use when Mongo Atlas Vector Search is the backing store.
- [Oracle AI Vector Search](https://docs.mem0.ai/components/vectordbs/dbs/oracledb) [OSS]: Use when Oracle Database AI Vector Search is the backing store.
- [Azure AI Search](https://docs.mem0.ai/components/vectordbs/dbs/azure) [OSS]: Use when the user is on Azure AI Search.
- [Azure MySQL](https://docs.mem0.ai/components/vectordbs/dbs/azure_mysql) [OSS]: Use when vector search runs on Azure Database for MySQL.
- [Redis](https://docs.mem0.ai/components/vectordbs/dbs/redis) [OSS]: Use when Redis Stack is the backing store.
-19
View File
@@ -1,19 +0,0 @@
<svg width="307" height="307" viewBox="0 0 307 307" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M162.496 25.3505C165.003 25.3505 167.453 24.6071 169.538 23.2144C171.622 21.8216 173.247 19.8419 174.206 17.5258C175.165 15.2097 175.416 12.6612 174.927 10.2024C174.438 7.74365 173.231 5.48516 171.458 3.71249C169.686 1.93983 167.427 0.73263 164.968 0.243552C162.51 -0.245525 159.961 0.00550576 157.645 0.964866C155.329 1.92423 153.349 3.54885 151.956 5.63328C150.564 7.71772 149.82 10.1683 149.82 12.6753C149.818 14.3404 150.145 15.9895 150.781 17.5283C151.417 19.0671 152.351 20.4653 153.528 21.6427C154.706 22.8201 156.104 23.7537 157.643 24.39C159.181 25.0262 160.83 25.3526 162.496 25.3505Z" fill="#9C58FA"/>
<path d="M69.3342 56.559C71.1066 54.7862 72.3135 52.5277 72.8024 50.069C73.2913 47.6103 73.0401 45.0619 72.0807 42.7459C71.1213 40.43 69.4967 38.4505 67.4123 37.0579C65.3279 35.6652 62.8774 34.9219 60.3706 34.9219C57.8637 34.9219 55.4132 35.6652 53.3288 37.0579C51.2444 38.4505 49.6198 40.43 48.6604 42.7459C47.701 45.0619 47.4498 47.6103 47.9387 50.069C48.4276 52.5277 49.6345 54.7862 51.4069 56.559C52.5839 57.7363 53.9813 58.6701 55.5193 59.3073C57.0573 59.9444 58.7058 60.2724 60.3706 60.2724C62.0353 60.2724 63.6838 59.9444 65.2218 59.3073C66.7598 58.6701 68.1572 57.7363 69.3342 56.559Z" fill="#9C58FA"/>
<path d="M25.3505 144.504C25.3505 141.997 24.6071 139.547 23.2143 137.462C21.8216 135.378 19.842 133.753 17.5259 132.794C15.2098 131.835 12.6612 131.584 10.2024 132.073C7.74368 132.562 5.48513 133.769 3.71247 135.542C1.9398 137.314 0.732655 139.573 0.243578 142.032C-0.2455 144.49 0.00543354 147.039 0.964793 149.355C1.92415 151.671 3.54877 153.651 5.63321 155.044C7.71764 156.436 10.1683 157.18 12.6752 157.18C16.0369 157.18 19.261 155.844 21.638 153.467C24.0151 151.09 25.3505 147.866 25.3505 144.504Z" fill="#9C58FA"/>
<path d="M56.5589 237.749C54.7862 235.976 52.5277 234.769 50.069 234.28C47.6103 233.792 45.0619 234.043 42.7459 235.002C40.43 235.962 38.4505 237.586 37.0579 239.671C35.6652 241.755 34.9219 244.206 34.9219 246.712C34.9219 249.219 35.6652 251.67 37.0579 253.754C38.4505 255.838 40.43 257.463 42.7459 258.423C45.0619 259.382 47.6103 259.633 50.069 259.144C52.5277 258.655 54.7862 257.448 56.5589 255.676C57.7362 254.499 58.6701 253.102 59.3073 251.564C59.9444 250.026 60.2724 248.377 60.2724 246.712C60.2724 245.048 59.9444 243.399 59.3073 241.861C58.6701 240.323 57.7362 238.926 56.5589 237.749Z" fill="#9C58FA"/>
<path d="M144.488 281.648C141.981 281.648 139.53 282.392 137.446 283.785C135.361 285.177 133.737 287.157 132.777 289.473C131.818 291.789 131.567 294.338 132.056 296.797C132.545 299.255 133.752 301.514 135.525 303.286C137.298 305.059 139.556 306.266 142.015 306.755C144.474 307.244 147.022 306.993 149.338 306.034C151.655 305.075 153.634 303.45 155.027 301.366C156.42 299.281 157.163 296.831 157.163 294.324C157.159 290.963 155.822 287.742 153.446 285.366C151.07 282.989 147.848 281.653 144.488 281.648Z" fill="#9C58FA"/>
<path d="M237.751 250.487C235.978 252.26 234.771 254.518 234.282 256.977C233.794 259.435 234.045 261.984 235.004 264.3C235.964 266.616 237.588 268.595 239.673 269.988C241.757 271.381 244.207 272.124 246.714 272.124C249.221 272.124 251.672 271.381 253.756 269.988C255.84 268.595 257.465 266.616 258.424 264.3C259.384 261.984 259.635 259.435 259.146 256.977C258.657 254.518 257.45 252.26 255.678 250.487C254.501 249.31 253.104 248.376 251.566 247.739C250.028 247.101 248.379 246.773 246.714 246.773C245.05 246.773 243.401 247.101 241.863 247.739C240.325 248.376 238.928 249.31 237.751 250.487Z" fill="#9C58FA"/>
<path d="M281.648 162.512C281.648 165.019 282.392 167.469 283.785 169.554C285.177 171.638 287.157 173.263 289.473 174.222C291.789 175.181 294.338 175.432 296.797 174.943C299.255 174.454 301.514 173.247 303.286 171.474C305.059 169.702 306.266 167.443 306.755 164.984C307.244 162.526 306.993 159.977 306.034 157.661C305.075 155.345 303.45 153.365 301.366 151.973C299.281 150.58 296.831 149.836 294.324 149.836C290.962 149.836 287.738 151.172 285.361 153.549C282.984 155.926 281.648 159.15 281.648 162.512Z" fill="#9C58FA"/>
<path d="M250.471 69.3303C252.244 71.1027 254.503 72.3097 256.961 72.7985C259.42 73.2874 261.968 73.0363 264.284 72.0768C266.6 71.1174 268.58 69.4928 269.972 67.4084C271.365 65.324 272.108 62.8735 272.108 60.3667C272.108 57.8599 271.365 55.4093 269.972 53.3249C268.58 51.2406 266.6 49.616 264.284 48.6565C261.968 47.6971 259.42 47.4459 256.961 47.9348C254.503 48.4236 252.244 49.6306 250.471 51.403C249.294 52.58 248.36 53.9775 247.723 55.5155C247.086 57.0535 246.758 58.7019 246.758 60.3667C246.758 62.0314 247.086 63.6799 247.723 65.2179C248.36 66.7559 249.294 68.1533 250.471 69.3303Z" fill="#9C58FA"/>
<path d="M184.782 60.8054C180.168 63.4713 178.3 69.0427 177.63 74.3267C177.047 78.9358 175.033 83.2457 171.87 86.6488C168.707 90.052 164.556 92.3766 160.002 93.2951C155.448 94.2136 150.721 93.6796 146.487 91.7683C142.252 89.857 138.724 86.6649 136.401 82.642C134.077 78.6192 133.075 73.9684 133.535 69.3455C133.995 64.7226 135.895 60.3607 138.966 56.8748C142.037 53.389 146.125 50.9549 150.653 49.9159C155.181 48.8768 159.921 49.2852 164.204 51.0834C169.121 53.1428 174.884 54.2762 179.514 51.6422C184.143 49.0082 185.995 43.4049 186.665 38.1208C187.245 33.5107 189.257 29.1987 192.418 25.7931C195.579 22.3876 199.73 20.0603 204.284 19.1394C208.838 18.2185 213.567 18.7505 217.803 20.6605C222.039 22.5704 225.568 25.7618 227.893 29.7846C230.218 33.8075 231.222 38.4587 230.763 43.0824C230.303 47.7062 228.404 52.0691 225.333 55.5559C222.262 59.0426 218.173 61.4773 213.645 62.5165C209.116 63.5557 204.375 63.147 200.091 61.3481C195.174 59.3048 189.411 58.1554 184.782 60.8054Z" fill="#9C58FA"/>
<path d="M110.073 65.8178C108.7 70.9742 111.318 76.2422 114.575 80.4567C117.417 84.1261 119.036 88.595 119.204 93.2335C119.372 97.872 118.08 102.446 115.51 106.311C112.941 110.177 109.223 113.138 104.881 114.778C100.538 116.419 95.7912 116.655 91.3077 115.454C86.8242 114.253 82.8306 111.675 79.8898 108.084C76.9489 104.493 75.2091 100.07 74.9155 95.4379C74.6219 90.8057 75.7894 86.1981 78.2533 82.2645C80.7173 78.331 84.3534 75.2698 88.6494 73.5124C93.5822 71.485 98.4991 68.2444 99.8241 63.0881C101.149 57.9317 98.579 52.6637 95.3224 48.4493C92.4827 44.7781 90.8665 40.3083 90.7018 35.6699C90.537 31.0315 91.8319 26.4583 94.4039 22.5949C96.976 18.7314 100.695 15.7724 105.038 14.1349C109.381 12.4974 114.128 12.2639 118.611 13.4673C123.094 14.6708 127.085 17.2505 130.024 20.8429C132.963 24.4354 134.7 28.8594 134.991 33.4916C135.283 38.1237 134.113 42.7305 131.647 46.6627C129.182 50.5948 125.544 53.6541 121.248 55.4095C116.363 57.4209 111.462 60.6775 110.073 65.8178Z" fill="#9C58FA"/>
<path d="M60.7892 122.218C63.4552 126.831 69.0425 128.699 74.3265 129.37C78.9361 129.955 83.2455 131.973 86.6471 135.138C90.0487 138.304 92.3707 142.457 93.2857 147.013C94.2006 151.569 93.6625 156.296 91.747 160.53C89.8314 164.763 86.6353 168.288 82.6093 170.608C78.5833 172.928 73.9305 173.926 69.3073 173.46C64.6841 172.995 60.3236 171.09 56.841 168.014C53.3583 164.938 50.9292 160.846 49.8962 156.316C48.8631 151.785 49.2783 147.045 51.0832 142.763C53.1426 137.846 54.2759 132.083 51.6419 127.454C49.0079 122.824 43.4046 120.973 38.1047 120.302C33.4951 119.717 29.1856 117.699 25.7841 114.533C22.3825 111.368 20.0604 107.214 19.1454 102.659C18.2304 98.1032 18.7687 93.3752 20.6842 89.1418C22.5997 84.9084 25.7959 81.3832 29.8219 79.0632C33.8479 76.7433 38.5006 75.7457 43.1238 76.2113C47.7471 76.6768 52.1075 78.582 55.5902 81.658C59.0728 84.7341 61.502 88.8258 62.535 93.3561C63.568 97.8865 63.1528 102.627 61.3479 106.908C59.2886 111.825 58.1552 117.588 60.7892 122.218Z" fill="#9C58FA"/>
<path d="M65.8204 196.93C70.9767 198.303 76.2287 195.685 80.4592 192.428C84.1286 189.586 88.5975 187.967 93.236 187.799C97.8745 187.631 102.449 188.923 106.314 191.493C110.179 194.062 113.141 197.78 114.781 202.122C116.421 206.464 116.657 211.212 115.457 215.695C114.256 220.179 111.678 224.172 108.087 227.113C104.496 230.054 100.073 231.794 95.4404 232.087C90.8082 232.381 86.2006 231.214 82.2671 228.75C78.3335 226.286 75.2723 222.649 73.5149 218.353C71.4875 213.421 68.231 208.504 63.0906 207.179C57.9503 205.854 52.6662 208.424 48.4518 211.681C44.7804 214.528 40.308 216.151 35.6652 216.32C31.0224 216.49 26.4435 215.199 22.5738 212.628C18.7042 210.057 15.7391 206.336 14.0968 201.99C12.4544 197.644 12.2176 192.892 13.4197 188.404C14.6218 183.917 17.2021 179.919 20.7969 176.976C24.3917 174.033 28.8195 172.293 33.4562 172C38.0929 171.708 42.7045 172.878 46.6407 175.345C50.577 177.813 53.6394 181.454 55.3961 185.755C57.4235 190.656 60.6641 195.541 65.8204 196.93Z" fill="#9C58FA"/>
<path d="M122.205 246.21C126.818 243.544 128.686 237.956 129.373 232.672C129.96 228.068 131.978 223.763 135.142 220.366C138.306 216.969 142.456 214.651 147.008 213.738C151.559 212.825 156.283 213.364 160.512 215.278C164.741 217.192 168.263 220.385 170.58 224.408C172.898 228.43 173.895 233.078 173.43 237.697C172.966 242.316 171.064 246.673 167.991 250.153C164.919 253.633 160.832 256.061 156.306 257.095C151.781 258.129 147.045 257.717 142.766 255.916C137.833 253.856 132.07 252.723 127.457 255.357C122.843 257.991 120.96 263.594 120.289 268.894C119.7 273.498 117.681 277.8 114.517 281.196C111.353 284.591 107.204 286.908 102.653 287.821C98.1027 288.733 93.3808 288.194 89.1525 286.281C84.9243 284.367 81.4031 281.175 79.085 277.154C76.767 273.134 75.7689 268.487 76.2316 263.869C76.6942 259.251 78.5942 254.895 81.6638 251.414C84.7334 247.933 88.8179 245.503 93.3417 244.466C97.8655 243.429 102.601 243.838 106.88 245.635C111.828 247.694 117.591 248.876 122.205 246.21Z" fill="#9C58FA"/>
<path d="M196.915 241.18C198.304 236.024 195.686 230.756 192.414 226.542C189.567 222.87 187.944 218.398 187.774 213.755C187.604 209.112 188.896 204.533 191.467 200.664C194.038 196.794 197.759 193.829 202.104 192.187C206.45 190.544 211.202 190.307 215.69 191.509C220.178 192.712 224.175 195.292 227.118 198.887C230.061 202.481 231.802 206.909 232.094 211.546C232.387 216.183 231.217 220.794 228.749 224.731C226.281 228.667 222.64 231.729 218.339 233.486C213.406 235.513 208.505 238.77 207.164 243.91C205.823 249.051 208.393 254.335 211.666 258.549C214.513 262.22 216.136 266.693 216.306 271.335C216.476 275.978 215.184 280.557 212.613 284.427C210.042 288.297 206.321 291.262 201.975 292.904C197.629 294.546 192.877 294.783 188.39 293.581C183.902 292.379 179.905 289.799 176.962 286.204C174.019 282.609 172.278 278.181 171.985 273.545C171.693 268.908 172.863 264.296 175.331 260.36C177.799 256.424 181.44 253.361 185.741 251.605C190.658 249.577 195.543 246.337 196.915 241.18Z" fill="#9C58FA"/>
<path d="M246.195 184.797C243.529 180.184 237.957 178.316 232.673 177.629C228.069 177.045 223.764 175.03 220.365 171.869C216.967 168.708 214.646 164.56 213.729 160.01C212.813 155.46 213.348 150.737 215.258 146.507C217.168 142.277 220.357 138.753 224.376 136.431C228.395 134.11 233.041 133.108 237.66 133.567C242.279 134.026 246.637 135.923 250.12 138.991C253.604 142.058 256.037 146.141 257.077 150.664C258.117 155.188 257.711 159.923 255.917 164.204C253.857 169.137 252.724 174.9 255.342 179.513C257.96 184.127 263.595 186.01 268.879 186.681C273.484 187.267 277.789 189.283 281.186 192.446C284.584 195.608 286.904 199.757 287.819 204.308C288.733 208.859 288.197 213.582 286.285 217.811C284.372 222.041 281.181 225.564 277.16 227.884C273.14 230.203 268.492 231.203 263.874 230.741C259.255 230.279 254.897 228.379 251.416 225.31C247.934 222.24 245.503 218.155 244.466 213.63C243.429 209.106 243.838 204.37 245.636 200.09C247.695 195.173 248.861 189.411 246.195 184.797Z" fill="#9C58FA"/>
<path d="M241.18 110.07C236.024 108.697 230.756 111.315 226.542 114.588C222.87 117.435 218.398 119.058 213.755 119.228C209.112 119.398 204.533 118.106 200.664 115.535C196.794 112.964 193.829 109.243 192.187 104.897C190.544 100.551 190.307 95.7994 191.509 91.3117C192.712 86.824 195.292 82.8268 198.887 79.8837C202.481 76.9405 206.909 75.2 211.546 74.9073C216.183 74.6147 220.794 75.7849 224.731 78.2527C228.667 80.7206 231.729 84.3617 233.486 88.6627C235.513 93.5955 238.754 98.4964 243.91 99.8374C249.066 101.178 254.335 98.6082 258.549 95.3356C262.22 92.4959 266.69 90.8798 271.328 90.7151C275.967 90.5503 280.54 91.8452 284.403 94.4172C288.267 96.9892 291.226 100.709 292.863 105.052C294.501 109.394 294.734 114.142 293.531 118.624C292.327 123.107 289.748 127.099 286.155 130.037C282.563 132.976 278.139 134.714 273.507 135.005C268.875 135.296 264.268 134.126 260.336 131.661C256.403 129.195 253.344 125.557 251.589 121.261C249.577 116.36 246.321 111.459 241.18 110.07Z" fill="#9C58FA"/>
<path d="M153.491 191.533C174.501 191.533 191.533 174.501 191.533 153.491C191.533 132.482 174.501 115.45 153.491 115.45C132.481 115.45 115.449 132.482 115.449 153.491C115.449 174.501 132.481 191.533 153.491 191.533Z" fill="#9C58FA"/>
</svg>

Before

Width:  |  Height:  |  Size: 13 KiB

+6 -5
View File
@@ -1,9 +1,10 @@
---
title: Memory Expiration
description: "Give a memory a shelf life: set an expiration date and it stops surfacing in search once that date passes, without deleting the record."
title: "Memory Expiration in Mem0"
sidebarTitle: "Memory Expiration"
description: "Set an expiration date on a Mem0 memory and it stops surfacing in search once that date passes. Nothing is deleted. Works on Platform and Open Source."
---
# Memory Expiration
## Why Use Memory Expiration?
Some facts are only true for a while. A trial plan ends, a seasonal preference goes stale, a support ticket ages past its retention window. Set an `expiration_date` on a memory and Mem0 stops surfacing it once that date passes, so you don't need a cleanup job hunting for rows to delete.
@@ -18,7 +19,7 @@ Some facts are only true for a while. A trial plan ends, a seasonal preference g
- **No expiration date means never expires.** That is the default for every memory.
- **Malformed dates fail open**: a stored value Mem0 can't parse is treated as *not* expired. A bad date never makes a memory silently vanish.
## Set an expiration date
## How do you set an expiration date on a memory?
Set it when you add the memory:
@@ -97,7 +98,7 @@ The same parameter and spelling work on the OSS `Memory` class. See <Link href="
Expired memories are dropped *before* your `top_k` is applied, so Mem0 widens the internal candidate pool first and short result sets are rare. They are not impossible: if nearly every memory in a scope has expired, a call can still return fewer than `top_k` results. Pass `show_expired: true` to get the full set back.
</Note>
## Clear an expiration date
## How do you clear or remove an expiration date?
Pass an explicit `None` (Python) or `null` (TypeScript) to make the memory permanent again. The SDKs deliberately preserve that null instead of treating it as "argument not supplied".
@@ -62,10 +62,6 @@ SECTION_MAP = {
"/open-source/features/rest-api",
"/open-source/configure-components",
],
"openmemory": [
"/openmemory/overview",
"/openmemory/quickstart",
],
"sdks": [
"/sdks/python",
"/sdks/js",
+9
View File
@@ -0,0 +1,9 @@
node_modules/
package-lock.json
.env
.zapierapprc
build/
dist/
coverage/
*.log
.DS_Store
+201
View File
@@ -0,0 +1,201 @@
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
(an example is provided in the Appendix below).
"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 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 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 those 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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2023] [Taranjeet Singh]
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.
+41
View File
@@ -0,0 +1,41 @@
# Zapier integration for Mem0
A [Zapier](https://zapier.com) integration for [Mem0](https://mem0.ai) — the memory layer for AI agents. Add, search, list, and delete long-term memories from any Zap.
Built with the [Zapier Platform CLI](https://docs.zapier.com/platform/quickstart/cli-tutorial).
## Actions
| Type | Name | Endpoint |
| --- | --- | --- |
| Create | **Add Memory** | `POST /v3/memories/add/` |
| Create | **Delete Memory** | `DELETE /v1/memories/{id}/` |
| Search | **Search Memories** | `POST /v3/memories/search/` |
| Search | **Get Memories** | `POST /v3/memories/` |
**Add Memory** runs LLM extraction asynchronously and returns immediately with an event ID by default. Turn on **Wait for Completion** to have the action poll until extraction finishes and return the resulting memories — note that extraction can take longer than Zapier allows a single step to run, and a timeout there does **not** mean the add failed (it typically still completes server-side). Set **Infer = false** to store the message verbatim instead of extracting.
**Get Memories** returns one page at a time; use the **Page** and **Limit** fields to page through larger result sets.
## Authentication
Custom (API key) auth. Provide a Mem0 API key from [app.mem0.ai](https://app.mem0.ai) → Settings → API Keys. It is sent as `Authorization: Token <key>`.
## Development
Written in TypeScript; the app compiles to `dist/` (Zapier runs the compiled JS).
```bash
pnpm install
pnpm build # compile src/ → dist/
pnpm test:unit # offline unit tests (mocked, no network)
MEM0_API_KEY=m0-... pnpm test # unit + live E2E against api.mem0.ai
```
Anonymous usage telemetry is sent to Mem0; opt out with `MEM0_TELEMETRY=false`.
To deploy (maintainers): `pnpm build && zapier push`.
## License
MIT
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@mem0/zapier",
"version": "0.1.0",
"description": "Zapier integration for Mem0 — the memory layer for AI agents.",
"keywords": [
"zapier",
"mem0",
"memory",
"ai",
"agents"
],
"homepage": "https://mem0.ai",
"author": {
"name": "Mem0",
"email": "founders@mem0.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/mem0ai/mem0",
"directory": "integrations/zapier-mem0"
},
"license": "Apache-2.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"test": "jest --testTimeout 180000",
"test:unit": "jest test/unit.test.ts"
},
"engines": {
"node": ">=18",
"npm": ">=5.6.0"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md",
"LICENSE"
],
"jest": {
"testEnvironment": "node",
"testMatch": ["**/test/**/*.test.ts"],
"setupFiles": ["<rootDir>/test/setup.ts"],
"transform": {
"^.+\\.tsx?$": ["ts-jest", {}]
}
},
"dependencies": {
"zapier-platform-core": "19.0.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.9.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.6.3"
},
"pnpm": {
"overrides": {
"form-data@<4.0.6": ">=4.0.6",
"uuid@<11.1.1": ">=11.1.1",
"esbuild": ">=0.28.1",
"undici@<6.27.0": ">=6.27.0 <8.0.0",
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
packages:
- '.'
overrides:
"form-data@<4.0.6": ">=4.0.6"
"uuid@<11.1.1": ">=11.1.1"
"esbuild": ">=0.28.1"
"undici@<6.27.0": ">=6.27.0 <8.0.0"
"undici@>=8.0.0 <8.5.0": ">=8.5.0"
@@ -0,0 +1,31 @@
import type { ZObject, Bundle } from './types';
// Custom (API key) authentication for Mem0.
// The key is sent as "Authorization: Token <apiKey>" (matches Mem0's SDKs).
const test = (z: ZObject, _bundle: Bundle) => z.request({ url: '/v1/ping/', method: 'GET' });
export default {
type: 'custom',
test,
fields: [
{
key: 'apiKey',
label: 'Mem0 API Key',
// `password` so Zapier masks the key in the connection UI (it is a secret).
type: 'password',
required: true,
helpText:
'Your Mem0 API key (starts with `m0-`). Create one at [app.mem0.ai](https://app.mem0.ai) → Settings → API Keys.',
},
{
key: 'baseUrl',
label: 'Base URL',
type: 'string',
required: false,
default: 'https://api.mem0.ai',
helpText: 'Override only for self-hosted or non-default deployments.',
},
],
// Shown on the connection label in the Zap editor.
connectionLabel: 'Mem0',
};
@@ -0,0 +1,168 @@
import type { ZObject, Bundle, AddResponse, EventResponse } from '../types';
import { captureEvent } from '../telemetry';
const POLL_INTERVAL_MS = 1500;
// Bounded so the poll budget stays under Zapier's per-step execution timeout.
const MAX_POLL_ATTEMPTS = 12;
// Polls GET /v1/event/{id}/ until the async memory-addition event resolves.
const pollEvent = async (z: ZObject, eventId: string): Promise<EventResponse> => {
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
const res = await z.request({ url: `/v1/event/${eventId}/`, method: 'GET' });
const data = res.data as EventResponse;
const status = data && data.status;
if (status === 'SUCCEEDED') {
return data;
}
if (status === 'FAILED') {
const reason = (data && (data.error || data.message)) || 'unknown error';
throw new z.errors.Error(`Mem0 memory event ${eventId} failed: ${reason}`, 'Mem0EventFailed', 400);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new z.errors.Error(
`Timed out waiting for memory event ${eventId}. The add was accepted and is ` +
`likely still completing on the server — a timeout here does not mean it failed.`,
'Mem0Timeout',
408,
);
};
const perform = async (z: ZObject, bundle: Bundle): Promise<AddResponse | EventResponse> => {
// Zapier boolean fields can arrive as the strings 'true'/'false'; coerce
// explicitly so "Infer = No" / "Wait = No" are honored.
const infer = String(bundle.inputData.infer) !== 'false';
// Waiting is opt-in (the poll path can exceed Zapier's step timeout).
const wait = String(bundle.inputData.waitForCompletion) === 'true';
const body: Record<string, unknown> = {
messages: [{ role: bundle.inputData.role || 'user', content: bundle.inputData.content }],
infer,
};
if (bundle.inputData.user_id) body.user_id = bundle.inputData.user_id;
if (bundle.inputData.agent_id) body.agent_id = bundle.inputData.agent_id;
if (bundle.inputData.run_id) body.run_id = bundle.inputData.run_id;
if (bundle.inputData.metadata) {
try {
body.metadata =
typeof bundle.inputData.metadata === 'string'
? JSON.parse(bundle.inputData.metadata)
: bundle.inputData.metadata;
} catch {
throw new z.errors.Error('Metadata must be valid JSON.', 'InvalidInput', 400);
}
}
// Custom extraction controls (optional): steer what the API extracts.
if (bundle.inputData.custom_instructions) {
body.custom_instructions = bundle.inputData.custom_instructions;
}
if (bundle.inputData.custom_categories) {
try {
body.custom_categories =
typeof bundle.inputData.custom_categories === 'string'
? JSON.parse(bundle.inputData.custom_categories)
: bundle.inputData.custom_categories;
} catch {
throw new z.errors.Error('Custom Categories must be valid JSON.', 'InvalidInput', 400);
}
}
if (bundle.inputData.includes) body.includes = bundle.inputData.includes;
if (bundle.inputData.excludes) body.excludes = bundle.inputData.excludes;
captureEvent('zapier.add_memory', bundle.authData?.apiKey, { infer, wait });
const response = await z.request({
url: '/v3/memories/add/',
method: 'POST',
body,
});
// Default to an empty object so an empty/no-content 2xx body can't crash the
// `.status` / `.event_id` reads below.
const data = (response.data ?? {}) as AddResponse;
// Add returns {event_id, status:PENDING|RUNNING}; poll only when opted in.
if (wait && data.event_id && data.status !== 'SUCCEEDED' && data.status !== 'FAILED') {
return pollEvent(z, data.event_id);
}
if (data.status === 'FAILED') {
const reason = data.error || data.message || 'unknown error';
throw new z.errors.Error(`Mem0 memory add failed: ${reason}`, 'Mem0EventFailed', 400);
}
return data;
};
export default {
key: 'add_memory',
noun: 'Memory',
display: {
label: 'Add Memory',
description: 'Extract and store memories from a message.',
},
operation: {
perform,
inputFields: [
{
key: 'content',
label: 'Content',
type: 'text',
required: true,
helpText: 'The message content to extract memories from.',
},
{
key: 'role',
label: 'Role',
choices: { user: 'User', assistant: 'Assistant', system: 'System' },
default: 'user',
},
{ key: 'user_id', label: 'User ID', type: 'string' },
{ key: 'agent_id', label: 'Agent ID', type: 'string' },
{ key: 'run_id', label: 'Run ID', type: 'string' },
{ key: 'metadata', label: 'Metadata (JSON)', type: 'string' },
{
key: 'custom_instructions',
label: 'Custom Instructions',
type: 'text',
helpText: 'Optional instructions that steer what the extractor keeps or ignores.',
},
{
key: 'custom_categories',
label: 'Custom Categories (JSON)',
type: 'string',
helpText:
'Optional taxonomy for categorising memories, as a JSON array of {category: description} objects.',
},
{
key: 'includes',
label: 'Includes',
type: 'text',
helpText: 'Optional: only extract memories matching this description.',
},
{
key: 'excludes',
label: 'Excludes',
type: 'text',
helpText: 'Optional: skip memories matching this description.',
},
{
key: 'infer',
label: 'Infer',
type: 'boolean',
default: 'true',
helpText: 'Run LLM extraction over the message. Turn off to store it verbatim.',
},
{
key: 'waitForCompletion',
label: 'Wait for Completion',
type: 'boolean',
default: 'false',
helpText:
'Poll until extraction finishes and return the resulting memories. ' +
'Leave off (default) to return immediately with an event ID — extraction can take ' +
'longer than Zapier allows this step to run, and a timeout does not mean the add failed.',
},
],
// Default (no-wait) returns the accepted event; the wait path returns the resolved event.
sample: { event_id: '00000000-0000-0000-0000-000000000000', status: 'PENDING' },
},
};
@@ -0,0 +1,24 @@
import type { ZObject, Bundle } from '../types';
const perform = async (z: ZObject, bundle: Bundle) => {
// Trailing slash required (Django APPEND_SLASH); id encoded so a stray slash can't mistarget the path.
const response = await z.request({
url: `/v1/memories/${encodeURIComponent(String(bundle.inputData.memory_id))}/`,
method: 'DELETE',
});
return response.data || { message: 'Deleted', memory_id: bundle.inputData.memory_id };
};
export default {
key: 'delete_memory',
noun: 'Memory',
display: {
label: 'Delete Memory',
description: 'Delete a single memory by its ID.',
},
operation: {
perform,
inputFields: [{ key: 'memory_id', label: 'Memory ID', type: 'string', required: true }],
sample: { message: 'Memory deleted successfully' },
},
};
+33
View File
@@ -0,0 +1,33 @@
import { version as platformVersion } from 'zapier-platform-core';
import authentication from './authentication';
import { includeApiKey, handleBadResponses } from './middleware';
import addMemory from './creates/add_memory';
import deleteMemory from './creates/delete_memory';
import searchMemories from './searches/search_memories';
import getMemories from './searches/get_memories';
import pkg from '../package.json';
const app = {
version: pkg.version,
platformVersion,
authentication,
beforeRequest: [includeApiKey],
afterResponse: [handleBadResponses],
creates: {
[addMemory.key]: addMemory,
[deleteMemory.key]: deleteMemory,
},
searches: {
[searchMemories.key]: searchMemories,
[getMemories.key]: getMemories,
},
resources: {},
triggers: {},
};
export = app;
@@ -0,0 +1,50 @@
import type { ZObject, Bundle, MutableRequest, ZResponse } from './types';
// Prepend the configured base URL and inject the auth header on every request.
export const includeApiKey = (
request: MutableRequest,
_z: ZObject,
bundle: Bundle,
): MutableRequest => {
if (bundle.authData && bundle.authData.apiKey) {
request.headers = request.headers || {};
request.headers.Authorization = `Token ${bundle.authData.apiKey}`;
}
// Resolve relative URLs against the configured base URL.
if (request.url && request.url.startsWith('/')) {
const base = (bundle.authData && bundle.authData.baseUrl) || 'https://api.mem0.ai';
request.url = `${base.replace(/\/$/, '')}${request.url}`;
}
return request;
};
// Surface HTTP failures as errors. z.request does NOT throw on non-2xx by
// default, so without this a 4xx/5xx would flow downstream as a fake success
// (empty search results / error body returned as a created memory).
export const handleBadResponses = (
response: ZResponse,
z: ZObject,
_bundle: Bundle,
): ZResponse => {
if (response.status === 401 || response.status === 403) {
throw new z.errors.Error(
'The Mem0 API key you supplied is invalid or lacks access.',
'AuthenticationError',
response.status,
);
}
if (response.status >= 400) {
const data = (response.data as Record<string, unknown>) || {};
const raw =
data.detail || data.error || data.message || response.content || 'unknown error';
// DRF sometimes returns detail as an object/array; stringify so the
// thrown message never renders as "[object Object]".
const detail = typeof raw === 'string' ? raw : JSON.stringify(raw);
throw new z.errors.Error(
`Mem0 API request failed (HTTP ${response.status}): ${detail}`,
'Mem0ApiError',
response.status,
);
}
return response;
};
@@ -0,0 +1,52 @@
import type { ZObject, Bundle, Memory } from '../types';
import { captureEvent } from '../telemetry';
const perform = async (z: ZObject, bundle: Bundle): Promise<Memory[]> => {
const body: Record<string, unknown> = {};
if (bundle.inputData.user_id) body.filters = { user_id: bundle.inputData.user_id };
captureEvent('zapier.get_memories', bundle.authData?.apiKey);
const response = await z.request({
url: '/v3/memories/',
method: 'POST',
params: {
page: Math.max(1, Math.floor(Number(bundle.inputData.page) || 1)),
page_size: Math.max(1, Math.floor(Number(bundle.inputData.limit) || 50)),
},
body,
});
// Guard against a null/empty body; always return an array.
const data = response.data as Memory[] | { results?: Memory[] } | null;
return Array.isArray(data) ? data : data?.results ?? [];
};
export default {
key: 'get_memories',
noun: 'Memory',
display: {
label: 'Get Memories',
description: 'List stored memories for a user.',
},
operation: {
perform,
inputFields: [
{ key: 'user_id', label: 'User ID', type: 'string', required: true },
{
key: 'limit',
label: 'Limit',
type: 'integer',
default: '50',
helpText: 'Max memories per page. Use Page to page through larger result sets.',
},
{
key: 'page',
label: 'Page',
type: 'integer',
default: '1',
helpText: 'Which page of results to return (1-based).',
},
],
sample: { id: '00000000-0000-0000-0000-000000000000', memory: 'User loves hiking' },
},
};
@@ -0,0 +1,40 @@
import type { ZObject, Bundle, Memory } from '../types';
import { captureEvent } from '../telemetry';
const perform = async (z: ZObject, bundle: Bundle): Promise<Memory[]> => {
const body: Record<string, unknown> = {
query: bundle.inputData.query,
output_format: 'v1.1',
top_k: Math.max(1, Math.floor(Number(bundle.inputData.limit) || 50)),
};
if (bundle.inputData.user_id) body.filters = { user_id: bundle.inputData.user_id };
captureEvent('zapier.search_memories', bundle.authData?.apiKey);
const response = await z.request({
url: '/v3/memories/search/',
method: 'POST',
body,
});
// Searches must return an array; guard against a null/empty body.
const data = response.data as Memory[] | { results?: Memory[] } | null;
return Array.isArray(data) ? data : data?.results ?? [];
};
export default {
key: 'search_memories',
noun: 'Memory',
display: {
label: 'Search Memories',
description: 'Semantic search over stored memories.',
},
operation: {
perform,
inputFields: [
{ key: 'query', label: 'Query', type: 'string', required: true },
{ key: 'user_id', label: 'User ID', type: 'string', required: true },
{ key: 'limit', label: 'Limit', type: 'integer', default: '50' },
],
sample: { id: '00000000-0000-0000-0000-000000000000', memory: 'User loves hiking' },
},
};
+39
View File
@@ -0,0 +1,39 @@
import { createHash } from 'crypto';
// Anonymous usage telemetry via PostHog, mirroring the Mem0 CLI / plugins.
// Fire-and-forget: never awaited, never throws, so it can neither slow down
// nor break an action. Opt out with MEM0_TELEMETRY=false.
const POSTHOG_API_KEY = 'phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX';
const POSTHOG_HOST = 'https://us.i.posthog.com/i/v0/e/';
export function captureEvent(
event: string,
apiKey: string | undefined,
properties: Record<string, unknown> = {},
): void {
if (process.env.MEM0_TELEMETRY === 'false') return;
try {
// Hash the API key so events are attributable to one account without
// ever transmitting the key itself.
const distinctId = apiKey ? createHash('md5').update(apiKey).digest('hex') : 'zapier-anon';
const payload = {
api_key: POSTHOG_API_KEY,
event,
distinct_id: distinctId,
properties: {
source: 'ZAPIER',
$process_person_profile: false,
...properties,
},
};
void fetch(POSTHOG_HOST, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
/* swallow — telemetry must never surface to the user */
});
} catch {
/* swallow — telemetry must never break the action */
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { ZObject, Bundle } from 'zapier-platform-core';
export type { ZObject, Bundle };
/** Mutable outgoing request, as seen by the beforeRequest middleware. */
export interface MutableRequest {
url?: string;
headers?: Record<string, string>;
[key: string]: unknown;
}
/** Response as seen by the afterResponse middleware. */
export interface ZResponse {
status: number;
data?: Record<string, unknown> | unknown[] | null;
content?: string;
[key: string]: unknown;
}
/** Async add response from POST /v3/memories/add/. */
export interface AddResponse {
event_id?: string;
status?: string;
error?: string;
message?: string;
results?: unknown[];
[key: string]: unknown;
}
/** GET /v1/event/{id}/ response. */
export interface EventResponse {
status?: string;
error?: string;
message?: string;
[key: string]: unknown;
}
/** A stored memory object returned by search / list. */
export interface Memory {
id?: string;
memory?: string;
[key: string]: unknown;
}
@@ -0,0 +1,96 @@
import * as zapier from 'zapier-platform-core';
import App from '../src';
const appTester = zapier.createAppTester(App as any);
const authData = {
apiKey: process.env.MEM0_API_KEY,
baseUrl: process.env.MEM0_BASE_URL || 'https://api.mem0.ai',
};
const userId = `zapier-e2e-${Date.now()}`;
// Retry an async op until `done` is satisfied or attempts run out. Extraction is
// async, so the default Add returns before the memory is searchable.
const until = async <T>(
fn: () => Promise<T>,
done: (r: T) => boolean,
{ attempts = 30, delayMs = 2000 } = {},
): Promise<T> => {
let last: T = undefined as unknown as T;
for (let i = 0; i < attempts; i++) {
last = await fn();
if (done(last)) return last;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return last;
};
// The E2E suite hits the live Mem0 API, so it only runs when MEM0_API_KEY is
// set (locally / with a secret). In CI without a key it is skipped, not failed.
const describeE2E = authData.apiKey ? describe : describe.skip;
describeE2E('Mem0 Zapier integration (E2E)', () => {
it('authentication.test succeeds', async () => {
const res: any = await appTester((App as any).authentication.test, { authData });
expect(res.status).toBe(200);
});
it('adds, searches, lists, and deletes a memory', async () => {
// Add via the default path: returns immediately with an event id.
const added: any = await appTester((App as any).creates.add_memory.operation.perform, {
authData,
inputData: {
content: 'I love hiking in the Alps and my favorite food is sushi',
user_id: userId,
},
});
expect(added.event_id).toBeDefined();
// Extraction is async; retry search until the memory is indexed.
// Budget generously — live extraction can occasionally exceed a minute.
const found: any[] = await until(
() =>
appTester((App as any).searches.search_memories.operation.perform, {
authData,
inputData: { query: 'outdoor activities', user_id: userId, limit: 5 },
}),
(r: any[]) => Array.isArray(r) && r.length > 0,
{ attempts: 60, delayMs: 2000 },
);
expect(Array.isArray(found)).toBe(true);
expect(found.length).toBeGreaterThan(0);
// Get all
const all: any[] = await appTester((App as any).searches.get_memories.operation.perform, {
authData,
inputData: { user_id: userId },
});
expect(Array.isArray(all)).toBe(true);
expect(all.length).toBeGreaterThan(0);
// Cleanup: delete every memory we created
for (const mem of all) {
await appTester((App as any).creates.delete_memory.operation.perform, {
authData,
inputData: { memory_id: mem.id },
});
}
const afterDelete: any[] = await appTester(
(App as any).searches.get_memories.operation.perform,
{ authData, inputData: { user_id: userId } },
);
expect(afterDelete.length).toBe(0);
});
it('surfaces API errors instead of returning an empty array (search needs a filter)', async () => {
// filters is required by the API; omitting it must throw, not return [].
await expect(
appTester((App as any).searches.search_memories.operation.perform, {
authData,
inputData: { query: 'anything' },
}),
).rejects.toThrow();
});
});
+3
View File
@@ -0,0 +1,3 @@
// Disable telemetry during tests so runs never fire real PostHog events
// (and so the fire-and-forget fetch cannot leave an open handle after tests).
process.env.MEM0_TELEMETRY = 'false';
+164
View File
@@ -0,0 +1,164 @@
// Offline unit tests: they mock `z.request`, so they run unconditionally in CI
// (unlike the live E2E suite in mem0.test.ts, gated on MEM0_API_KEY). They cover
// what `zapier validate` can't: boolean coercion, URL join, metadata, array shapes.
import addMemory from '../src/creates/add_memory';
import deleteMemory from '../src/creates/delete_memory';
import searchMemories from '../src/searches/search_memories';
import getMemories from '../src/searches/get_memories';
import { includeApiKey } from '../src/middleware';
// Minimal `z` stub: hands back queued responses and records every request.
const makeZ = (responses: any[] = []): any => {
const queue = [...responses];
const requests: any[] = [];
return {
requests,
request: async (opts: any) => {
requests.push(opts);
const next = queue.shift();
return next !== undefined ? next : { data: {} };
},
errors: {
Error: class Mem0Error extends Error {
name: string;
status?: number;
constructor(message: string, name?: string, status?: number) {
super(message);
this.name = name || 'Error';
this.status = status;
}
},
},
};
};
describe('add_memory (offline)', () => {
it('coerces infer="false" to a boolean and does not poll by default', async () => {
const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]);
const res = await addMemory.operation.perform(z, {
inputData: { content: 'hi', user_id: 'u1', infer: 'false' },
} as any);
// waitForCompletion defaults off -> a single request (the add), no poll.
expect(z.requests).toHaveLength(1);
expect(z.requests[0].body.infer).toBe(false);
expect((res as any).status).toBe('PENDING');
});
it('polls the event only when waitForCompletion="true"', async () => {
const z = makeZ([
{ data: { event_id: 'e1', status: 'PENDING' } },
{ data: { status: 'SUCCEEDED', results: [{ id: 'm1' }] } },
]);
const res = await addMemory.operation.perform(z, {
inputData: { content: 'hi', user_id: 'u1', waitForCompletion: 'true' },
} as any);
expect(z.requests).toHaveLength(2);
expect(z.requests[1].url).toBe('/v1/event/e1/');
expect((res as any).status).toBe('SUCCEEDED');
});
it('throws a clear error on invalid JSON metadata', async () => {
const z = makeZ();
await expect(
addMemory.operation.perform(z, {
inputData: { content: 'hi', user_id: 'u1', metadata: '{not json' },
} as any),
).rejects.toThrow('Metadata must be valid JSON.');
});
it('forwards custom_instructions and parses custom_categories JSON', async () => {
const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]);
await addMemory.operation.perform(z, {
inputData: {
content: 'hi',
user_id: 'u1',
custom_instructions: 'keep durable facts',
custom_categories: '[{"work":"job related"}]',
},
} as any);
expect(z.requests[0].body.custom_instructions).toBe('keep durable facts');
expect(z.requests[0].body.custom_categories).toEqual([{ work: 'job related' }]);
});
it('throws a clear error on invalid Custom Categories JSON', async () => {
const z = makeZ();
await expect(
addMemory.operation.perform(z, {
inputData: { content: 'hi', user_id: 'u1', custom_categories: '{bad' },
} as any),
).rejects.toThrow('Custom Categories must be valid JSON.');
});
it('forwards includes and excludes when provided', async () => {
const z = makeZ([{ data: { event_id: 'e1', status: 'PENDING' } }]);
await addMemory.operation.perform(z, {
inputData: { content: 'hi', user_id: 'u1', includes: 'work facts', excludes: 'small talk' },
} as any);
expect(z.requests[0].body.includes).toBe('work facts');
expect(z.requests[0].body.excludes).toBe('small talk');
});
});
describe('search / get array-shape enforcement (offline)', () => {
it('search unwraps an object {results:[...]} into an array', async () => {
const z = makeZ([{ data: { results: [{ id: 'm1' }] } }]);
const res = await searchMemories.operation.perform(z, {
inputData: { query: 'x', user_id: 'u1' },
} as any);
expect(Array.isArray(res)).toBe(true);
expect(res).toHaveLength(1);
});
it('get_memories returns [] when the API returns neither array nor results', async () => {
const z = makeZ([{ data: {} }]);
const res = await getMemories.operation.perform(z, { inputData: { user_id: 'u1' } } as any);
expect(Array.isArray(res)).toBe(true);
expect(res).toHaveLength(0);
});
it('get_memories forwards page and page_size as numbers', async () => {
const z = makeZ([{ data: { results: [] } }]);
await getMemories.operation.perform(z, {
inputData: { user_id: 'u1', page: '2', limit: '10' },
} as any);
expect(z.requests[0].params).toEqual({ page: 2, page_size: 10 });
});
it('search and get_memories return [] on a null/empty body (no crash)', async () => {
const zSearch = makeZ([{ data: null }]);
const found = await searchMemories.operation.perform(zSearch, {
inputData: { query: 'x', user_id: 'u1' },
} as any);
expect(found).toEqual([]);
const zGet = makeZ([{ data: null }]);
const all = await getMemories.operation.perform(zGet, { inputData: { user_id: 'u1' } } as any);
expect(all).toEqual([]);
});
});
describe('delete_memory (offline)', () => {
it('encodes the memory id in the URL path', async () => {
const z = makeZ([{ data: {} }]);
await deleteMemory.operation.perform(z, { inputData: { memory_id: 'a/b c' } } as any);
expect(z.requests[0].url).toBe('/v1/memories/a%2Fb%20c/');
});
});
describe('includeApiKey middleware (offline)', () => {
it('prepends the base URL and injects the auth header', () => {
const req = includeApiKey({ url: '/v3/memories/' }, null as any, {
authData: { apiKey: 'k', baseUrl: 'https://api.mem0.ai/' },
} as any);
expect(req.url).toBe('https://api.mem0.ai/v3/memories/');
expect(req.headers!.Authorization).toBe('Token k');
});
it('leaves absolute URLs untouched', () => {
const req = includeApiKey({ url: 'https://other.example/x' }, null as any, {
authData: { apiKey: 'k' },
} as any);
expect(req.url).toBe('https://other.example/x');
});
});
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": false,
"sourceMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.1.1",
"version": "3.1.2",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+9 -5
View File
@@ -100,12 +100,16 @@ export class AnthropicLLM implements LLM {
return { content, role: "assistant", toolCalls };
}
const firstBlock = response.content[0];
if (firstBlock.type === "text") {
return firstBlock.text;
} else {
throw new Error("Unexpected response type from Anthropic API");
// Thinking-enabled responses put a thinking block before the text block,
// and a response can carry no text block at all, so find the text block
// like the tools branch above instead of indexing content[0]. Mirrors the
// Python provider (#6481).
for (const block of response.content) {
if (block.type === "text") {
return block.text;
}
}
return "";
}
async generateChat(messages: Message[]): Promise<LLMResponse> {
+9
View File
@@ -59,6 +59,15 @@ export class GoogleLLM implements LLM {
];
}
// Honor a requested JSON response format (parity with the Python SDK's
// mem0/llms/gemini.py). Gemini's structured output is opt-in via
// responseMimeType — without it the model is never told to emit JSON, so
// callers passing json_object silently get free-form text and depend on a
// fragile markdown-fence strip downstream.
if (responseFormat?.type === "json_object") {
config.responseMimeType = "application/json";
}
const completion = await this.google.models.generateContent({
contents,
model: this.model,
+48
View File
@@ -440,4 +440,52 @@ describe("generateWhereClause", () => {
ChromaDB.generateWhereClause({ $not: [{ age: { gt: 18 } }] }),
).toEqual({ age: { $lte: 18 } });
});
// Regression tests for the three where-clause translation bugs fixed in
// Python by #6452 and tracked for the TS SDK in #6513. ChromaDB allows
// exactly one operator or field per dict level.
it("keeps both bounds of a same-field range as $and-combined clauses", () => {
expect(ChromaDB.generateWhereClause({ age: { gte: 18, lte: 65 } })).toEqual(
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] },
);
});
it("keeps same-field ranges inside $or branches", () => {
expect(
ChromaDB.generateWhereClause({
$or: [{ age: { gte: 18, lte: 65 } }, { vip: true }],
}),
).toEqual({
$or: [
{ $and: [{ age: { $gte: 18 } }, { age: { $lte: 65 } }] },
{ vip: { $eq: true } },
],
});
});
it("wraps multi-field conditions inside $or in $and", () => {
expect(
ChromaDB.generateWhereClause({
$or: [{ age: { gte: 18 }, vip: true }, { city: "sh" }],
}),
).toEqual({
$or: [
{ $and: [{ age: { $gte: 18 } }, { vip: { $eq: true } }] },
{ city: { $eq: "sh" } },
],
});
});
it("negates $not contains/icontains instead of dropping the clause", () => {
expect(
ChromaDB.generateWhereClause({
$not: [{ title: { contains: "draft" } }],
}),
).toEqual({ title: { $ne: "draft" } });
expect(
ChromaDB.generateWhereClause({
$not: [{ title: { icontains: "draft" } }],
}),
).toEqual({ title: { $ne: "draft" } });
});
});
+37 -1
View File
@@ -267,6 +267,36 @@ describe("Milvus vector store (TS OSS SDK)", () => {
});
});
it("skips a wildcard '*' filter value and keeps the rest", async () => {
const client = new FakeMilvusClient({ existing: ["mem0"] });
client.searchResponse = { results: [] };
const store = makeStore(client, { metricType: "COSINE" });
await store.initialize();
// "*" means match-any: it must be dropped, not emitted as `== "*"` (which
// matches nothing), leaving only the real agent_id clause.
await store.search([0.1, 0.2, 0.3], 5, {
user_id: "*",
agent_id: "a1",
});
const searchCall = client.calls.find((c) => c.method === "search")!;
expect(searchCall.args.filter).toBe('(metadata["agent_id"] == "a1")');
});
it("omits the filter entirely when every value is a wildcard", async () => {
const client = new FakeMilvusClient({ existing: ["mem0"] });
const store = makeStore(client);
await store.initialize();
await store.list({ user_id: "*" });
// All clauses dropped, so list() falls back to its match-all "" filter
// rather than a literal `(metadata["user_id"] == "*")` that matches nothing.
const queryCall = client.calls.filter((c) => c.method === "query").pop()!;
expect(queryCall.args.filter).toBe("");
});
it("normalises L2 distances into a 0..1 similarity score", async () => {
const client = new FakeMilvusClient({ existing: ["mem0"] });
client.searchResponse = {
@@ -432,12 +462,18 @@ describe("Milvus vector store (TS OSS SDK)", () => {
["a"],
[{ data: "hello world", text_lemmatized: "hello world lemma" }],
);
await store.insert(
[[0.2, 0.3, 0.4]],
["c"],
[{ data: "hello world", textLemmatized: "hello world camel" }],
);
// Falls back to raw data when there is no lemmatized text.
await store.insert([[0.4, 0.5, 0.6]], ["b"], [{ data: "just data" }]);
const insertCalls = client.calls.filter((c) => c.method === "insert");
expect(insertCalls[0].args.data[0].text).toBe("hello world lemma");
expect(insertCalls[1].args.data[0].text).toBe("just data");
expect(insertCalls[1].args.data[0].text).toBe("hello world camel");
expect(insertCalls[2].args.data[0].text).toBe("just data");
});
it("writes the BM25 text field on update for a BM25 collection", async () => {
+103 -1
View File
@@ -5,6 +5,7 @@ const mockFindOne = jest.fn();
const mockUpdateOne = jest.fn();
const mockListSearchIndexes = jest.fn();
const mockCreateSearchIndex = jest.fn();
const mockDropSearchIndex = jest.fn();
const mockDrop = jest.fn();
const mockToArray = jest.fn();
const mockLimit = jest.fn().mockReturnThis();
@@ -26,6 +27,7 @@ const mockCollection = {
updateOne: mockUpdateOne,
listSearchIndexes: mockListSearchIndexes,
createSearchIndex: mockCreateSearchIndex,
dropSearchIndex: mockDropSearchIndex,
drop: mockDrop,
find: mockFind,
aggregate: mockAggregate,
@@ -74,6 +76,25 @@ describe("MongoDB Vector Store", () => {
await store.close();
});
const expectedTextSearchIndexDefinition = {
name: "test_col_text_search_index",
definition: {
mappings: {
dynamic: false,
fields: {
payload: {
type: "document",
fields: {
data: { type: "string" },
textLemmatized: { type: "string" },
text_lemmatized: { type: "string" },
},
},
},
},
},
};
it("should initialize client and check/create collection and indexes", async () => {
await store.initialize();
@@ -84,6 +105,83 @@ describe("MongoDB Vector Store", () => {
});
expect(mockCollection.deleteOne).toHaveBeenCalledWith({ _id: 0 });
expect(mockCreateSearchIndex).toHaveBeenCalledTimes(2);
expect(mockCreateSearchIndex).toHaveBeenCalledWith(
expectedTextSearchIndexDefinition,
);
});
it("should drop and recreate a stale text search index on upgrade", async () => {
mockListCollections.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]),
});
mockListSearchIndexes.mockReturnValue({
toArray: jest.fn().mockResolvedValue([
{ name: "test_col_vector_index" },
{
name: "test_col_text_search_index",
definition: {
mappings: {
dynamic: false,
fields: {
payload: {
type: "document",
fields: {
data: { type: "string" },
text_lemmatized: { type: "string" },
},
},
},
},
},
},
]),
});
await store.initialize();
expect(mockDropSearchIndex).toHaveBeenCalledWith(
"test_col_text_search_index",
);
expect(mockCreateSearchIndex).toHaveBeenCalledTimes(1);
expect(mockCreateSearchIndex).toHaveBeenCalledWith(
expectedTextSearchIndexDefinition,
);
});
it("should not recreate a text search index that already has textLemmatized", async () => {
mockListCollections.mockReturnValue({
toArray: jest.fn().mockResolvedValue([{ name: "test_col" }]),
});
mockListSearchIndexes.mockReturnValue({
toArray: jest.fn().mockResolvedValue([
{ name: "test_col_vector_index" },
{
name: "test_col_text_search_index",
latestDefinition: expectedTextSearchIndexDefinition.definition,
},
]),
});
await store.initialize();
expect(mockDropSearchIndex).not.toHaveBeenCalled();
expect(mockCreateSearchIndex).not.toHaveBeenCalled();
});
it("should map payload.textLemmatized in the text search index", async () => {
await store.initialize();
const textIndexCall = mockCreateSearchIndex.mock.calls.find(
([arg]: any[]) => arg.name === "test_col_text_search_index",
);
expect(textIndexCall).toBeDefined();
expect(textIndexCall![0].definition.mappings.fields.payload.fields).toEqual(
{
data: { type: "string" },
text_lemmatized: { type: "string" },
textLemmatized: { type: "string" },
},
);
});
it("should insert documents correctly", async () => {
@@ -197,7 +295,11 @@ describe("MongoDB Vector Store", () => {
index: "test_col_text_search_index",
text: {
query: "test",
path: ["payload.data", "payload.text_lemmatized"],
path: [
"payload.data",
"payload.text_lemmatized",
"payload.textLemmatized",
],
},
},
},
+37 -17
View File
@@ -455,44 +455,64 @@ export class CassandraDB implements VectorStore {
return value.includes(payloadValue);
}
// Every operator present in a compound condition must hold (AND), so check
// them all instead of returning on the first match. Returning early meant a
// range like { gte: 10, lte: 20 } only applied `gte`. Mirrors the databricks
// store's matcher.
let sawOperator = false;
if ("eq" in value) {
return payloadValue === value.eq;
sawOperator = true;
if (payloadValue !== value.eq) return false;
}
if ("ne" in value) {
return payloadValue !== value.ne;
sawOperator = true;
if (payloadValue === value.ne) return false;
}
if ("gt" in value) {
return payloadValue > value.gt;
sawOperator = true;
if (!(payloadValue > value.gt)) return false;
}
if ("gte" in value) {
return payloadValue >= value.gte;
sawOperator = true;
if (!(payloadValue >= value.gte)) return false;
}
if ("lt" in value) {
return payloadValue < value.lt;
sawOperator = true;
if (!(payloadValue < value.lt)) return false;
}
if ("lte" in value) {
return payloadValue <= value.lte;
sawOperator = true;
if (!(payloadValue <= value.lte)) return false;
}
if ("in" in value) {
return Array.isArray(value.in) && value.in.includes(payloadValue);
sawOperator = true;
if (!Array.isArray(value.in) || !value.in.includes(payloadValue))
return false;
}
if ("nin" in value) {
return !Array.isArray(value.nin) || !value.nin.includes(payloadValue);
sawOperator = true;
if (Array.isArray(value.nin) && value.nin.includes(payloadValue))
return false;
}
if ("contains" in value) {
return (
typeof payloadValue === "string" &&
payloadValue.includes(value.contains)
);
sawOperator = true;
if (
typeof payloadValue !== "string" ||
!payloadValue.includes(value.contains)
)
return false;
}
if ("icontains" in value) {
return (
typeof payloadValue === "string" &&
payloadValue.toLowerCase().includes(value.icontains.toLowerCase())
);
sawOperator = true;
if (
typeof payloadValue !== "string" ||
!payloadValue.toLowerCase().includes(value.icontains.toLowerCase())
)
return false;
}
return payloadValue === value;
return sawOperator ? true : payloadValue === value;
}
private filterVector(
+55 -54
View File
@@ -273,14 +273,14 @@ export class ChromaDB implements VectorStore {
private static convertCondition(
key: string,
value: any,
): Record<string, any> | null {
): Array<Record<string, any>> {
// Wildcard - ChromaDB has no direct wildcard, so skip this filter.
if (value === "*") {
return null;
return [];
}
if (Array.isArray(value)) {
return { [key]: { $in: value } };
return [{ [key]: { $in: value } }];
}
if (value !== null && typeof value === "object") {
@@ -294,19 +294,31 @@ export class ChromaDB implements VectorStore {
in: "$in",
nin: "$nin",
};
const condition: Record<string, any> = {};
for (const [op, val] of Object.entries(value)) {
if (op in opMap) {
condition[key] = { [opMap[op]]: val };
} else {
// contains/icontains and unknown operators fall back to equality.
condition[key] = { $eq: val };
}
}
return condition;
// ChromaDB allows exactly one operator per field expression, so each
// operator becomes its own clause (combined with $and by the caller).
// Previously each operator overwrote the last, silently dropping range
// bounds. contains/icontains and unknown operators fall back to
// equality.
return Object.entries(value).map(([op, val]) => ({
[key]: { [opMap[op] ?? "$eq"]: val },
}));
}
return { [key]: { $eq: value } };
return [{ [key]: { $eq: value } }];
}
/** Combine clauses under a logical operator, unwrapping singletons. */
private static combineClauses(
clauses: Array<Record<string, any>>,
operator: "$and" | "$or",
): Record<string, any> | null {
if (clauses.length === 0) {
return null;
}
if (clauses.length === 1) {
return clauses[0];
}
return { [operator]: clauses };
}
/**
@@ -337,66 +349,55 @@ export class ChromaDB implements VectorStore {
if (key === "$or" || key === "OR") {
const orConditions: any[] = [];
for (const condition of value as any[]) {
const built: Record<string, any> = {};
const subClauses: Array<Record<string, any>> = [];
for (const [subKey, subValue] of Object.entries(condition)) {
const converted = ChromaDB.convertCondition(subKey, subValue);
if (converted) Object.assign(built, converted);
subClauses.push(...ChromaDB.convertCondition(subKey, subValue));
}
if (Object.keys(built).length > 0) orConditions.push(built);
}
if (orConditions.length > 1) {
processed.push({ $or: orConditions });
} else if (orConditions.length === 1) {
processed.push(orConditions[0]);
// Multi-field conditions must be wrapped in $and — ChromaDB rejects
// flat objects with more than one field per level.
const combined = ChromaDB.combineClauses(subClauses, "$and");
if (combined) orConditions.push(combined);
}
const combinedOr = ChromaDB.combineClauses(orConditions, "$or");
if (combinedOr) processed.push(combinedOr);
} else if (key === "$not" || key === "NOT") {
// De Morgan: NOT(a AND b) is (NOT a) OR (NOT b), so the negated fields
// within one condition are combined with $or, and separate conditions
// are combined with $and. This mirrors the Python SDK's ChromaDB port.
const negatedPerGroup: any[] = [];
for (const condition of value as any[]) {
const negatedFields: any[] = [];
const negatedFields: Array<Record<string, any>> = [];
for (const [subKey, subValue] of Object.entries(condition)) {
if (subValue !== null && typeof subValue === "object") {
for (const [op, val] of Object.entries(subValue as any)) {
const neg = negateOp[op];
if (neg) {
const converted = ChromaDB.convertCondition(subKey, {
[neg]: val,
});
if (converted) negatedFields.push(converted);
}
// Unknown operators mirror the positive-path equality
// fallback as inequality (previously they were silently
// dropped, which could erase the entire NOT clause).
const neg = negateOp[op] ?? "ne";
negatedFields.push(
...ChromaDB.convertCondition(subKey, { [neg]: val }),
);
}
} else {
const converted = ChromaDB.convertCondition(subKey, {
ne: subValue,
});
if (converted) negatedFields.push(converted);
negatedFields.push(
...ChromaDB.convertCondition(subKey, { ne: subValue }),
);
}
}
if (negatedFields.length > 1) {
negatedPerGroup.push({ $or: negatedFields });
} else if (negatedFields.length === 1) {
negatedPerGroup.push(negatedFields[0]);
}
}
if (negatedPerGroup.length > 1) {
processed.push({ $and: negatedPerGroup });
} else if (negatedPerGroup.length === 1) {
processed.push(negatedPerGroup[0]);
const combined = ChromaDB.combineClauses(negatedFields, "$or");
if (combined) negatedPerGroup.push(combined);
}
const combinedNot = ChromaDB.combineClauses(negatedPerGroup, "$and");
if (combinedNot) processed.push(combinedNot);
} else {
const converted = ChromaDB.convertCondition(key, value);
if (converted) processed.push(converted);
const combined = ChromaDB.combineClauses(
ChromaDB.convertCondition(key, value),
"$and",
);
if (combined) processed.push(combined);
}
}
if (processed.length === 0) {
return undefined;
}
if (processed.length === 1) {
return processed[0];
}
return { $and: processed };
return ChromaDB.combineClauses(processed, "$and") ?? undefined;
}
}
+11 -4
View File
@@ -235,6 +235,13 @@ export class Milvus implements VectorStore {
if (!Milvus.SAFE_FILTER_KEY.test(key)) {
throw new Error(`Invalid filter key: ${JSON.stringify(key)}`);
}
if (value === "*") {
// Wildcard - match any value. Milvus has no direct wildcard, so skip
// the clause rather than emitting a literal `== "*"` that matches
// nothing. Mirrors the Python provider (#6187) and the chroma/pinecone
// stores.
continue;
}
if (typeof value === "string") {
// Escape backslashes before quotes so a value can't break out of the
// string literal (order matters, exactly as in the Python provider).
@@ -252,13 +259,13 @@ export class Milvus implements VectorStore {
}
/**
* Text fed to the BM25 sparse index for a payload. Prefers the lemmatized
* text, falls back to the raw memory `data`, and truncates to the VarChar
* limit (mirrors the Python provider).
* Text fed to the BM25 sparse index for a payload. Prefers `textLemmatized`,
* then `text_lemmatized`, then raw `data`; truncates to the VarChar limit.
*/
private bm25Text(payload?: Record<string, any>): string {
if (!payload) return "";
const raw = payload.text_lemmatized || payload.data || "";
const raw =
payload.textLemmatized || payload.text_lemmatized || payload.data || "";
return String(raw).slice(0, 65535);
}
+57 -21
View File
@@ -62,6 +62,46 @@ export class MongoDB implements VectorStore {
return this._initPromise;
}
private textSearchIndexDefinition(textIndexName: string) {
return {
name: textIndexName,
definition: {
mappings: {
dynamic: false,
fields: {
payload: {
type: "document",
fields: {
data: { type: "string" },
textLemmatized: { type: "string" },
text_lemmatized: { type: "string" },
},
},
},
},
},
};
}
private textSearchIndexMappingIsCurrent(
index: Record<string, unknown>,
): boolean {
const definition =
(index.latestDefinition as Record<string, unknown> | undefined) ??
(index.definition as Record<string, unknown> | undefined);
const payloadFields = (
(definition?.mappings as Record<string, unknown> | undefined)?.fields as
| Record<string, unknown>
| undefined
)?.payload as Record<string, unknown> | undefined;
const fields =
(payloadFields?.fields as Record<string, unknown> | undefined) ?? {};
const textLemmatized = fields.textLemmatized as
| { type?: string }
| undefined;
return textLemmatized?.type === "string";
}
private async _doInitialize(): Promise<void> {
await this.ensureClient();
try {
@@ -111,32 +151,24 @@ export class MongoDB implements VectorStore {
// Create Text Search Index for keywordSearch
const textIndexName = `${this.collectionName}_text_search_index`;
try {
let foundTextIndex = false;
let existingTextIndex: Record<string, unknown> | null = null;
try {
const indexes = await this.collection.listSearchIndexes().toArray();
foundTextIndex = indexes.some((idx) => idx.name === textIndexName);
existingTextIndex =
(indexes.find((idx) => idx.name === textIndexName) as
| Record<string, unknown>
| undefined) ?? null;
} catch (e) {
// ignore
}
if (!foundTextIndex) {
await this.collection.createSearchIndex({
name: textIndexName,
definition: {
mappings: {
dynamic: false,
fields: {
payload: {
type: "document",
fields: {
data: { type: "string" },
text_lemmatized: { type: "string" },
},
},
},
},
},
});
const textSearchIndex = this.textSearchIndexDefinition(textIndexName);
if (!existingTextIndex) {
await this.collection.createSearchIndex(textSearchIndex);
} else if (!this.textSearchIndexMappingIsCurrent(existingTextIndex)) {
await this.collection.dropSearchIndex(textIndexName);
await this.collection.createSearchIndex(textSearchIndex);
}
} catch (e: any) {
console.warn(
@@ -286,7 +318,11 @@ export class MongoDB implements VectorStore {
index: textIndexName,
text: {
query: query,
path: ["payload.data", "payload.text_lemmatized"],
path: [
"payload.data",
"payload.text_lemmatized",
"payload.textLemmatized",
],
},
},
},
@@ -276,6 +276,7 @@ export class OpenSearchDB implements VectorStore {
should: [
{ match: { "payload.data": query } },
{ match: { "payload.text_lemmatized": query } },
{ match: { "payload.textLemmatized": query } },
],
minimum_should_match: 1,
};
@@ -72,6 +72,37 @@ describe("AnthropicLLM (unit)", () => {
expect(callArgs.tool_choice).toBeUndefined();
});
// Regression: thinking-enabled models emit a thinking block before the text
// block. Indexing content[0] threw "Unexpected response type"; the text block
// must be found by type instead (TS parity with #6481).
it("returns the text block when a thinking block precedes it (no tools)", async () => {
mockCreate.mockResolvedValueOnce({
content: [
{ type: "thinking", thinking: "Let me reason about this." },
{ type: "text", text: '{"facts": ["fact1"]}' },
],
});
const llm = new AnthropicLLM({ apiKey: "test-key" });
const result = await llm.generateResponse([
{ role: "user", content: "Hello" },
]);
expect(result).toBe('{"facts": ["fact1"]}');
});
// A response carrying no text block at all must resolve to "" rather than throw.
it("returns an empty string when no text block is present (no tools)", async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: "thinking", thinking: "Thinking only." }],
});
const llm = new AnthropicLLM({ apiKey: "test-key" });
await expect(
llm.generateResponse([{ role: "user", content: "Hello" }]),
).resolves.toBe("");
});
// Bug #1 regression: bare string "auto" must NOT be sent; object form required
it("forwards tool_choice as { type: 'auto' } (not bare string) when tools are provided", async () => {
mockCreate.mockResolvedValueOnce({
@@ -0,0 +1,63 @@
/// <reference types="jest" />
/**
* Cassandra vector store — filter matching unit tests.
*
* Cassandra has no server-side metadata filter, so search()/list() scan rows
* and apply filters in-app via matchFieldCondition(). These tests drive that
* matcher through the public list() API with an injected fake client.
*/
import { CassandraDB } from "../src/vector_stores/cassandra";
type Row = { id: string; payload: Record<string, any> };
// Minimal fake driver: CREATE statements during initialize() return nothing;
// a SELECT returns the seeded rows in one page (pageState undefined => stop).
function fakeClient(rows: Row[]) {
return {
async connect() {},
async execute(query: string) {
if (/^\s*SELECT/i.test(query)) {
return { rows, pageState: undefined };
}
return { rows: [], pageState: undefined };
},
async shutdown() {},
};
}
function makeStore(rows: Row[]) {
return new CassandraDB({
keyspace: "mem0",
collectionName: "mem0",
dimension: 3,
client: fakeClient(rows) as any,
} as any);
}
describe("CassandraDB filter matching", () => {
const rows: Row[] = [
{ id: "a", payload: { data: "a", age: 5 } },
{ id: "b", payload: { data: "b", age: 15 } },
{ id: "c", payload: { data: "c", age: 25 } },
];
it("applies every operator in a compound range filter (not just the first)", async () => {
const store = makeStore(rows);
// age in [10, 20]: only "b" (15) qualifies. The old matcher returned on the
// first operator (gte), so "c" (25) leaked through because lte was ignored.
const [results] = await store.list({ age: { gte: 10, lte: 20 } });
expect(results.map((r) => r.id)).toEqual(["b"]);
});
it("still matches a single-operator filter", async () => {
const store = makeStore(rows);
const [results] = await store.list({ age: { gte: 15 } });
expect(results.map((r) => r.id).sort()).toEqual(["b", "c"]);
});
it("treats a plain equality filter as before", async () => {
const store = makeStore(rows);
const [results] = await store.list({ age: 15 });
expect(results.map((r) => r.id)).toEqual(["b"]);
});
});
+31
View File
@@ -185,6 +185,37 @@ describe("GoogleLLM (unit)", () => {
expect(response.toolCalls[1].name).toBe("add_graph_memory");
});
// Regression: generateResponse accepted a responseFormat argument but never
// forwarded it, so Gemini was never told to emit JSON (parity with the Python
// SDK's mem0/llms/gemini.py, which sets response_mime_type/response_schema).
it("forwards json_object responseFormat as responseMimeType", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: '{"facts": ["fact1"]}',
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
await llm.generateResponse([{ role: "user", content: "Extract facts" }], {
type: "json_object",
});
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.responseMimeType).toBe("application/json");
});
it("does not set responseMimeType when no responseFormat is given", async () => {
mockGenerateContent.mockResolvedValueOnce({
text: "plain text",
functionCalls: null,
});
const llm = new GoogleLLM({ apiKey: "test-key" });
await llm.generateResponse([{ role: "user", content: "Hello" }]);
const callArgs = mockGenerateContent.mock.calls[0][0];
expect(callArgs.config.responseMimeType).toBeUndefined();
});
it("formats generateChat messages and joins Gemini response parts", async () => {
mockGenerateContent.mockResolvedValueOnce({
candidates: [
@@ -153,4 +153,26 @@ describe("OpenSearchDB", () => {
await expect(store.get("missing")).resolves.toBeNull();
});
it("keywordSearch queries lemmatized payload fields", async () => {
const client = createClient();
const store = await createStore(client);
await store.keywordSearch("stud french", 5, { user_id: "alice" });
const searchCall = client.search.mock.calls.find(
([arg]: any[]) => arg.index === collectionName,
);
expect(searchCall).toBeDefined();
const should = searchCall![0].body.query.bool.should;
expect(should).toContainEqual({
match: { "payload.textLemmatized": "stud french" },
});
expect(should).toContainEqual({
match: { "payload.text_lemmatized": "stud french" },
});
expect(should).toContainEqual({
match: { "payload.data": "stud french" },
});
});
});
+113
View File
@@ -0,0 +1,113 @@
"""Pydantic configuration for the Oracle AI Vector Search integration."""
import re
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
def _quote_identifier(name: str) -> str:
name = name.strip()
reg = r'^(?:"[^"]+"|[^".]+)(?:\.(?:"[^"]+"|[^".]+))*$'
pattern_validate = re.compile(reg)
if not pattern_validate.match(name):
raise ValueError(f"Identifier name {name} is not valid.")
pattern_match = r'"([^"]+)"|([^".]+)'
groups = re.findall(pattern_match, name)
groups = [m[0] or m[1] for m in groups]
groups = [f'"{g}"' for g in groups]
return ".".join(groups)
class HnswParams(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
neighbors: Optional[int] = Field(None, ge=2, le=2048)
efconstruction: Optional[int] = Field(None, ge=1, le=65535)
class IvfParams(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
neighbor_partitions: Optional[int] = Field(None, alias="neighbor partitions", ge=1, le=10_000_000)
samples_per_partition: Optional[int] = Field(None, ge=1)
min_vectors_per_partition: Optional[int] = Field(None, ge=0)
class OracleAIVectorSearchConfig(BaseModel):
"""Configuration required to connect to an Oracle database with vector search enabled."""
connection_params: Optional[dict] = Field(None, description="Database connection parameters, including auth.")
use_connection_pool: bool = Field(
True,
description="Create a ConnectionPool instead of a single Connection when no client is provided",
)
client: Optional[Any] = Field(
None, description="Oracle Connection or ConnectionPool (overrides connection string and individual parameters)"
)
collection_name: str = Field("mem0", description="Default name for the collection")
embedding_model_dims: int = Field(1536, description="Dimension of the embedding vectors")
distance_metric: Literal["EUCLIDEAN", "EUCLIDEAN_SQUARED", "COSINE", "DOT", "HAMMING", "MANHATTAN"] = Field(
"COSINE",
description="Similarity metric: EUCLIDEAN, EUCLIDEAN_SQUARED, COSINE, DOT, HAMMING or MANHATTAN. Defaults to COSINE",
)
do_create_index: Optional[bool] = Field(True, description="Optional whether to create index")
index_type: Literal["HNSW", "IVF"] = Field("HNSW", description="Optional index type, HNSW or IVF")
index_name: Optional[str] = Field(None, description="Optional custom name for the vector index")
index_parameters: Optional[dict] = Field(
None,
description="Optional structured CREATE VECTOR INDEX parameters",
)
index_accuracy: Optional[int] = Field(None, description="Optional index accuracy")
@field_validator("distance_metric", "index_type", mode="before")
@classmethod
def _normalize_uppercase(cls, value: Any) -> Any:
return value.upper() if isinstance(value, str) else value
@model_validator(mode="after")
def _validate_model(self):
"""Normalise attributes and validate identifiers/metrics."""
if not self.connection_params and not self.client:
raise ValueError("Must provide at least one of `connection_params` and `client`")
if self.index_name is None:
self.index_name = f"{self.collection_name}_VEC_IDX"
self.index_name = _quote_identifier(self.index_name)
self.collection_name = _quote_identifier(self.collection_name)
if self.index_parameters is not None:
parameter_model = HnswParams if self.index_type == "HNSW" else IvfParams
self.index_parameters = parameter_model.model_validate(self.index_parameters).model_dump(
by_alias=True,
exclude_none=True,
)
if self.index_accuracy and not (0 < self.index_accuracy <= 100):
raise ValueError("`index_accuracy` must be between 1 and 100")
if not (0 < self.embedding_model_dims):
raise ValueError("`embedding_model_dims` must be bigger than 0")
return self
@model_validator(mode="before")
@classmethod
def validate_extra_fields(cls, values: Dict[str, Any]) -> Dict[str, Any]:
allowed_fields = set(cls.model_fields.keys())
extra_fields = set(values.keys()) - allowed_fields
if extra_fields:
raise ValueError(
"Extra fields not allowed: {}. Please input only the following fields: {}".format(
", ".join(sorted(extra_fields)), ", ".join(sorted(allowed_fields))
)
)
return values
+1
View File
@@ -201,6 +201,7 @@ class VectorStoreFactory:
"cassandra": "mem0.vector_stores.cassandra.CassandraDB",
"neptune": "mem0.vector_stores.neptune_analytics.NeptuneAnalyticsVector",
"turbopuffer": "mem0.vector_stores.turbopuffer.TurbopufferDB",
"oracledb": "mem0.vector_stores.oracledb.OracleAIVectorSearch",
}
@classmethod
+79 -78
View File
@@ -254,110 +254,111 @@ class ChromaDB(VectorStoreBase):
def _generate_where_clause(where: dict[str, any]) -> dict[str, any]:
"""
Generate a properly formatted where clause for ChromaDB.
ChromaDB's where grammar allows exactly one field or one logical
operator per dict level, so multiple operators on the same field and
multi-field conditions must be combined with an explicit ``$and``.
Args:
where (dict[str, any]): The filter conditions.
Returns:
dict[str, any]: Properly formatted where clause for ChromaDB.
"""
if where is None:
return None
def convert_condition(key: str, value: any) -> dict:
"""Convert universal filter format to ChromaDB format."""
op_map = {
"eq": "$eq",
"ne": "$ne",
"gt": "$gt",
"gte": "$gte",
"lt": "$lt",
"lte": "$lte",
"in": "$in",
"nin": "$nin",
}
# Negation of each operator. contains/icontains fall back to equality
# on the positive path (ChromaDB has no substring match), so their
# negation falls back to inequality for consistency.
negate_map = {
"eq": "$ne",
"ne": "$eq",
"gt": "$lte",
"gte": "$lt",
"lt": "$gte",
"lte": "$gt",
"in": "$nin",
"nin": "$in",
}
def convert_condition(key: str, value: any) -> list:
"""Convert one field condition to a list of single-field ChromaDB clauses."""
if value == "*":
# Wildcard - match any value (ChromaDB doesn't have direct wildcard, so we skip this filter)
return []
if isinstance(value, dict):
# One clause per operator: ChromaDB rejects field expressions
# with more than one operator, so a range like
# {"gte": 18, "lte": 65} must become two clauses combined
# with $and by the caller (previously each operator
# overwrote the last, silently dropping bounds).
# contains/icontains and unknown operators fall back to equality.
return [{key: {op_map.get(op, "$eq"): val}} for op, val in value.items()]
# Simple equality
return [{key: {"$eq": value}}]
def combine(clauses: list, operator: str):
"""Combine clauses under a logical operator, unwrapping singletons."""
if not clauses:
return None
elif isinstance(value, dict):
# Handle comparison operators
chroma_condition = {}
for op, val in value.items():
if op == "eq":
chroma_condition[key] = {"$eq": val}
elif op == "ne":
chroma_condition[key] = {"$ne": val}
elif op == "gt":
chroma_condition[key] = {"$gt": val}
elif op == "gte":
chroma_condition[key] = {"$gte": val}
elif op == "lt":
chroma_condition[key] = {"$lt": val}
elif op == "lte":
chroma_condition[key] = {"$lte": val}
elif op == "in":
chroma_condition[key] = {"$in": val}
elif op == "nin":
chroma_condition[key] = {"$nin": val}
elif op in ["contains", "icontains"]:
# ChromaDB doesn't support contains, fallback to equality
chroma_condition[key] = {"$eq": val}
else:
# Unknown operator, treat as equality
chroma_condition[key] = {"$eq": val}
return chroma_condition
else:
# Simple equality
return {key: {"$eq": value}}
if len(clauses) == 1:
return clauses[0]
return {operator: clauses}
processed_filters = []
for key, value in where.items():
if key == "$or":
# Handle OR conditions
or_conditions = []
for condition in value:
or_condition = {}
sub_clauses = []
for sub_key, sub_value in condition.items():
converted = convert_condition(sub_key, sub_value)
if converted:
or_condition.update(converted)
if or_condition:
or_conditions.append(or_condition)
if len(or_conditions) > 1:
processed_filters.append({"$or": or_conditions})
elif len(or_conditions) == 1:
processed_filters.append(or_conditions[0])
sub_clauses.extend(convert_condition(sub_key, sub_value))
combined = combine(sub_clauses, "$and")
if combined:
or_conditions.append(combined)
combined_or = combine(or_conditions, "$or")
if combined_or:
processed_filters.append(combined_or)
elif key == "$not":
negate_op = {
"eq": "$ne", "ne": "$eq",
"gt": "$lte", "gte": "$lt",
"lt": "$gte", "lte": "$gt",
"in": "$nin", "nin": "$in",
}
negated_per_group = []
for condition in value:
negated_fields = []
for sub_key, sub_value in condition.items():
if isinstance(sub_value, dict):
for op, val in sub_value.items():
neg = negate_op.get(op)
if neg:
negated_fields.append({sub_key: {neg: val}})
# Unknown operators mirror the positive-path
# equality fallback as inequality (previously
# they were silently dropped, which could
# erase the entire NOT clause).
negated_fields.append({sub_key: {negate_map.get(op, "$ne"): val}})
else:
negated_fields.append({sub_key: {"$ne": sub_value}})
if len(negated_fields) > 1:
negated_per_group.append({"$or": negated_fields})
elif len(negated_fields) == 1:
negated_per_group.append(negated_fields[0])
# NOT(a AND b) == (NOT a) OR (NOT b)
combined = combine(negated_fields, "$or")
if combined:
negated_per_group.append(combined)
combined_not = combine(negated_per_group, "$and")
if combined_not:
processed_filters.append(combined_not)
if len(negated_per_group) > 1:
processed_filters.append({"$and": negated_per_group})
elif len(negated_per_group) == 1:
processed_filters.append(negated_per_group[0])
else:
# Regular condition
converted = convert_condition(key, value)
if converted:
processed_filters.append(converted)
combined = combine(convert_condition(key, value), "$and")
if combined:
processed_filters.append(combined)
# Return appropriate format based on number of conditions
if len(processed_filters) == 0:
return None
elif len(processed_filters) == 1:
return processed_filters[0]
else:
return {"$and": processed_filters}
return combine(processed_filters, "$and")
+1
View File
@@ -35,6 +35,7 @@ class VectorStoreConfig(BaseModel):
"langchain": "LangchainConfig",
"s3_vectors": "S3VectorsConfig",
"turbopuffer": "TurbopufferConfig",
"oracledb": "OracleAIVectorSearchConfig",
}
@model_validator(mode="after")
+6 -4
View File
@@ -298,10 +298,12 @@ class MilvusDB(VectorStoreBase):
if payload is None:
payload = existing[0].get("metadata")
text = ""
if payload:
text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535]
schema = {"id": vector_id, "vectors": vector, "metadata": payload, "text": text}
schema = {"id": vector_id, "vectors": vector, "metadata": payload}
if self._has_bm25_schema:
text = ""
if payload:
text = (payload.get("text_lemmatized") or payload.get("data", ""))[:65535]
schema["text"] = text
self.client.upsert(collection_name=self.collection_name, data=schema)
def get(self, vector_id) -> Optional[OutputData]:
+34 -24
View File
@@ -16,6 +16,7 @@ from mem0.vector_stores.base import VectorStoreBase
logger = logging.getLogger(__name__)
_SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*$")
_IDENTITY_FILTER_KEYS = ("user_id", "agent_id", "run_id")
def _validate_filter(key: str, value) -> None:
@@ -28,6 +29,29 @@ def _validate_filter(key: str, value) -> None:
)
def _build_filter_clauses(filters):
"""Build term clauses from every filter key, not just the identity keys."""
filter_clauses = []
for key, value in (filters or {}).items():
if value is None:
continue
if value == "*":
# "Any value" wildcard (a documented Platform pattern): match
# documents where the field exists — as opensearch.ts already
# does for every key — instead of a literal, near-always-empty
# term match on the string "*".
_validate_filter(key, value)
filter_clauses.append({"exists": {"field": f"payload.{key}"}})
continue
if key not in _IDENTITY_FILTER_KEYS and not isinstance(value, (str, int, float, bool)):
logger.debug(f"Ignoring non-scalar filter value for key {key!r}")
continue
_validate_filter(key, value)
field = f"payload.{key}.keyword" if isinstance(value, str) else f"payload.{key}"
filter_clauses.append({"term": {field: value}})
return filter_clauses
class OutputData(BaseModel):
id: str
score: float
@@ -204,13 +228,7 @@ class OpenSearchDB(VectorStoreBase):
query_body = {"size": top_k * 2, "query": None}
# Prepare filter conditions if applicable
filter_clauses = []
if filters:
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
_validate_filter(key, value)
filter_clauses.append({"term": {f"payload.{key}.keyword": value}})
filter_clauses = _build_filter_clauses(filters)
# Combine knn with filters if needed
if filter_clauses:
@@ -230,7 +248,7 @@ class OpenSearchDB(VectorStoreBase):
return results
except Exception as e:
logger.error(f"Error during search: {e}", exc_info=True)
return []
raise
def keyword_search(self, query, top_k=5, filters=None):
"""Search for memories using BM25 keyword matching.
@@ -255,13 +273,7 @@ class OpenSearchDB(VectorStoreBase):
}
# Apply filters consistently with the existing search() method
filter_clauses = []
if filters:
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
_validate_filter(key, value)
filter_clauses.append({"term": {f"payload.{key}.keyword": value}})
filter_clauses = _build_filter_clauses(filters)
if filter_clauses:
bool_query["filter"] = filter_clauses
@@ -281,8 +293,12 @@ class OpenSearchDB(VectorStoreBase):
]
return results
except Exception as e:
logger.error(f"Error during keyword search: {e}")
return []
# Do NOT re-raise here: keyword_search() is a best-effort helper that
# search() may call to augment semantic results. Raising would crash
# the whole search() call on a keyword-only failure (regression per
# maintainer review on #6519). Log with exc_info and degrade to None.
logger.error(f"Error during keyword search: {e}", exc_info=True)
return None
def delete(self, vector_id: str) -> None:
"""Delete a vector by custom ID."""
@@ -370,13 +386,7 @@ class OpenSearchDB(VectorStoreBase):
"""List all memories with optional filters."""
query: Dict = {"query": {"match_all": {}}}
filter_clauses = []
if filters:
for key in ["user_id", "run_id", "agent_id"]:
value = filters.get(key)
if value:
_validate_filter(key, value)
filter_clauses.append({"term": {f"payload.{key}.keyword": value}})
filter_clauses = _build_filter_clauses(filters)
if filter_clauses:
query["query"] = {"bool": {"filter": filter_clauses}}
+592
View File
@@ -0,0 +1,592 @@
"""Oracle AI Vector Search vector store integration for mem0."""
import array
import json
import logging
import math
import re
import uuid
from contextlib import contextmanager
from typing import Any, Dict, List, Optional
try:
import oracledb
except ImportError as exc: # pragma: no cover - dependency guard
raise ImportError("Oracle AI Vector Search requires the 'oracledb' package.") from exc
from pydantic import BaseModel
from mem0.configs.vector_stores.oracledb import OracleAIVectorSearchConfig
from mem0.vector_stores.base import VectorStoreBase
logger = logging.getLogger(__name__)
class OutputData(BaseModel):
"""Standard output structure returned from vector operations."""
id: Optional[str]
score: Optional[float]
payload: Optional[Dict[str, Any]]
# Allow letters, digits, underscore, dot, brackets, comma, *, space (for 'to')
METADATA_PATTERN = re.compile(r"[a-zA-Z0-9_\.\[\],\s\*]+")
def _validate_metadata_key(metadata_key: str) -> None:
if not METADATA_PATTERN.fullmatch(metadata_key):
raise ValueError(
f"Invalid metadata key '{metadata_key}'. "
"Only letters, numbers, underscores, nesting via '.', "
"and array wildcards '[*]' are allowed."
)
_SCORE_FROM_DISTANCE = {
"COSINE": lambda d: max(0.0, min(1.0, 1.0 - d)),
"EUCLIDEAN": lambda d: 1.0 / (1.0 + max(0.0, d)),
"EUCLIDEAN_SQUARED": lambda d: 1.0 / (1.0 + math.sqrt(max(0.0, d))),
"HAMMING": lambda d: 1.0 / (1.0 + max(0.0, d)),
"MANHATTAN": lambda d: 1.0 / (1.0 + max(0.0, d)),
"DOT": lambda d: -d,
}
def _convert_distance_to_score(distance: float, metric: str) -> float:
try:
return _SCORE_FROM_DISTANCE[metric.upper()](distance)
except KeyError:
raise ValueError(f"Unsupported distance metric: {metric}") from None
_FIELD_OPERATORS = {"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "contains", "icontains"}
_COMPARISON_OPERATORS = {
"eq": "==",
"ne": "!=",
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
}
_LOGICAL_OPERATORS = {
"$and": "and",
"$or": "or",
"$not": "not",
"AND": "and",
"OR": "or",
"NOT": "not",
}
def _json_path(metadata_key: str) -> str:
_validate_metadata_key(metadata_key)
path_parts: List[str] = []
for part in metadata_key.split("."):
if part.endswith("[*]"):
path_parts.append(f'."{part[:-3]}"[*]')
else:
path_parts.append(f'."{part}"')
return "".join(path_parts)
def _bind_filter_value(value: Any, params: Dict[str, Any]) -> tuple[str, str]:
param = f"f_{len(params)}"
params[param] = value
return f"${param}", f':{param} AS "{param}"'
def _json_exists(json_path: str, predicate: str, passings: List[str]) -> str:
passing_clause = f" PASSING {', '.join(passings)}" if passings else ""
return f"JSON_EXISTS(payload, '${json_path}?({predicate})'{passing_clause})"
def _validate_scalar_operand(operator: str, value: Any) -> None:
if isinstance(value, (dict, list, tuple, set)):
raise ValueError(f"Oracle filter operator {operator!r} requires a scalar value")
def _build_field_condition(metadata_key: str, value: Any, params: Dict[str, Any]) -> str:
json_path = _json_path(metadata_key)
if value == "*":
return f"JSON_EXISTS(payload, '${json_path}')"
if not isinstance(value, dict):
_validate_scalar_operand("eq", value)
if value is None:
return _json_exists(json_path, "@ == null", [])
variable, passing = _bind_filter_value(value, params)
return _json_exists(json_path, f"@ == {variable}", [passing])
if not value:
raise ValueError(f"Operator filter for field {metadata_key!r} must not be empty")
unsupported = set(value) - _FIELD_OPERATORS
if unsupported:
raise ValueError(
f"Unsupported Oracle filter operator(s) for field {metadata_key!r}: "
f"{', '.join(sorted(map(str, unsupported)))}"
)
predicates: List[str] = []
passings: List[str] = []
additional_clauses: List[str] = []
for operator, operand in value.items():
if operator in _COMPARISON_OPERATORS:
_validate_scalar_operand(operator, operand)
if operand is None:
if operator not in {"eq", "ne"}:
raise ValueError(f"Oracle filter operator {operator!r} does not support null")
predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} null")
continue
variable, passing = _bind_filter_value(operand, params)
predicates.append(f"@ {_COMPARISON_OPERATORS[operator]} {variable}")
passings.append(passing)
continue
if operator in {"in", "nin"}:
if not isinstance(operand, (list, tuple)) or not operand:
raise ValueError(f"Oracle filter operator {operator!r} requires a non-empty list")
variables: List[str] = []
list_passings: List[str] = []
for item in operand:
_validate_scalar_operand(operator, item)
if item is None:
variables.append("null")
continue
variable, passing = _bind_filter_value(item, params)
variables.append(variable)
list_passings.append(passing)
membership = _json_exists(json_path, f"@ in ({', '.join(variables)})", list_passings)
if operator == "in":
additional_clauses.append(membership)
else:
additional_clauses.append(f"NOT ({membership})")
continue
if not isinstance(operand, str):
raise ValueError(f"Oracle filter operator {operator!r} requires a string value")
if operator == "contains":
variable, passing = _bind_filter_value(operand, params)
predicates.append(f"@ has substring {variable}")
passings.append(passing)
else:
variable, passing = _bind_filter_value(operand.lower(), params)
predicates.append(f"@.lower() has substring {variable}")
passings.append(passing)
clauses = list(additional_clauses)
if predicates:
clauses.insert(0, _json_exists(json_path, " && ".join(predicates), passings))
if len(clauses) == 1:
return clauses[0]
return "(" + " AND ".join(clauses) + ")"
def _build_filter_group(filters: Dict[str, Any], params: Dict[str, Any]) -> str:
if not isinstance(filters, dict) or not filters:
raise ValueError("Oracle filter groups must be non-empty dictionaries")
clauses: List[str] = []
for key, value in filters.items():
if key in _LOGICAL_OPERATORS:
if not isinstance(value, list) or not value:
raise ValueError(f"Logical filter operator {key!r} requires a non-empty list")
nested = [_build_filter_group(condition, params) for condition in value]
logical_operator = _LOGICAL_OPERATORS[key]
if logical_operator == "not":
clauses.append(f"NOT ({' OR '.join(nested)})")
else:
joiner = " AND " if logical_operator == "and" else " OR "
clauses.append("(" + joiner.join(nested) + ")")
continue
if key.startswith("$"):
raise ValueError(f"Unsupported Oracle logical filter operator: {key}")
clauses.append(_build_field_condition(key, value, params))
if len(clauses) == 1:
return clauses[0]
return "(" + " AND ".join(clauses) + ")"
class OracleAIVectorSearch(VectorStoreBase):
"""Oracle AI Vector Search backend for mem0."""
def __init__(self, **kwargs: Any) -> None:
self.config = OracleAIVectorSearchConfig(**kwargs)
self.collection_name = self.config.collection_name
if self.config.client:
logger.debug("Using Oracle connection pool: %s", self.config.client)
self.client = self.config.client
self._owns_client = False
elif self.config.use_connection_pool:
pool_kwargs = {
"min": 1,
"max": 4,
}
pool_kwargs.update(self.config.connection_params)
logger.debug("Creating Oracle connection pool")
self.client = oracledb.create_pool(**pool_kwargs)
self._owns_client = True
else:
logger.debug("Creating Oracle connection")
self.client = oracledb.connect(**self.config.connection_params)
self._owns_client = True
if not (hasattr(self.client, "thin") and self.client.thin):
if oracledb.clientversion()[:2] < (23, 4):
raise RuntimeError(
f"Oracle DB client driver version {'.'.join(map(str, oracledb.clientversion()))} "
"not supported, must be >=23.4 for vector support"
)
if isinstance(self.client, oracledb.Connection):
db_version = tuple([int(v) for v in self.client.version.split(".")])
else:
with self.client.acquire() as conn:
db_version = tuple([int(v) for v in conn.version.split(".")])
if db_version < (23, 4):
raise ValueError(
f"Oracle DB version {'.'.join(map(str, db_version))} not supported, must be >=23.4 for vector support"
)
self.create_col()
@contextmanager
def _get_cursor(self, commit: bool = False):
if isinstance(self.client, oracledb.ConnectionPool):
with self.client.acquire() as connection:
with connection.cursor() as cursor:
try:
yield cursor
if commit:
connection.commit()
except Exception:
connection.rollback()
raise
else:
with self.client.cursor() as cursor:
try:
yield cursor
if commit:
self.client.commit()
except Exception:
self.client.rollback()
raise
# Utility helpers --------------------------------------------------
@staticmethod
def _load_payload(value: Any) -> Dict[str, Any]:
if value is None:
return {}
if isinstance(value, dict):
return value
if hasattr(value, "read"):
value = value.read()
if isinstance(value, bytes):
value = value.decode("utf-8")
try:
return json.loads(value)
except json.JSONDecodeError:
logger.debug("Failed to decode payload JSON")
raise
@staticmethod
def _catalog_name(name: str) -> str:
return name.replace('"', "")
def _create_index_ddl(self) -> str:
accuracy_str = ""
if self.config.index_accuracy:
accuracy_str = f"WITH TARGET ACCURACY {self.config.index_accuracy}"
parameters = self._index_parameters()
parameters_str = f"PARAMETERS ({parameters})" if parameters else ""
distance_metric = self.config.distance_metric
create_index = (
f"CREATE VECTOR INDEX IF NOT EXISTS {self.config.index_name} ON {self.collection_name} (vector) "
f"ORGANIZATION {'INMEMORY NEIGHBOR GRAPH' if self.config.index_type == 'HNSW' else 'NEIGHBOR PARTITIONS'}"
f" DISTANCE {distance_metric} {accuracy_str} {parameters_str}"
)
return create_index
def _index_parameters(self) -> str:
index_parameters = self.config.index_parameters
if not index_parameters:
return ""
parameters = [f"type {self.config.index_type}"]
parameters.extend(f"{key} {value}" for key, value in index_parameters.items())
return ", ".join(parameters)
# Vector store API -------------------------------------------------
def create_col(self) -> None:
"""
Create a new collection (table in Oracle).
Will also initialize vector search index if specified.
"""
with self._get_cursor(commit=True) as cursor:
cursor.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.collection_name} (
id VARCHAR2(36) PRIMARY KEY,
vector VECTOR({self.config.embedding_model_dims}),
payload JSON
)
"""
)
if self.config.do_create_index:
ddl = self._create_index_ddl()
cursor.execute(ddl)
def insert(
self,
vectors: List[List[float]],
payloads: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
) -> None:
logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}")
if payloads is not None and len(payloads) != len(vectors):
raise ValueError(f"Payload count must match vector count. Expected {len(vectors)} got {len(payloads)}.")
if ids is not None and len(ids) != len(vectors):
raise ValueError(f"ID count must match vector count. Expected {len(vectors)} got {len(ids)}.")
ids = ids or [str(uuid.uuid4()) for _ in vectors]
data = [
{"id": _id, "vector": array.array("f", vector), "payload": payload}
for vector, payload, _id in zip(vectors, payloads or [{}] * len(vectors), ids)
]
with self._get_cursor(commit=True) as cursor:
cursor.setinputsizes(
vector=oracledb.DB_TYPE_VECTOR,
payload=oracledb.DB_TYPE_JSON,
)
cursor.executemany(
f"INSERT INTO {self.collection_name} (id, vector, payload) VALUES (:id, :vector, :payload)", data
)
def search(
self,
query: str,
vectors: List[float],
top_k: int = 5,
filters: Optional[Dict[str, Any]] = None,
) -> List[OutputData]:
"""
Search for similar vectors using the vector search index.
Args:
query (str): Query string
vectors (List[float]): Query vector.
top_k (int, optional): Number of results to return. Defaults to 5.
filters (Dict, optional): Filters to apply to the search.
Returns:
List[OutputData]: Search results.
"""
filter_clause, params = self._build_filters(filters)
distance_metric = self.config.distance_metric
sql = (
f"SELECT id, payload, VECTOR_DISTANCE(vector, :query_vec, {distance_metric}) distance "
f"FROM {self.collection_name} {filter_clause} ORDER BY distance FETCH APPROX FIRST :limit ROWS ONLY"
)
with self._get_cursor() as cursor:
cursor.execute(sql, query_vec=array.array("f", vectors), limit=top_k, **params)
rows = cursor.fetchall()
return [
OutputData(
id=row[0],
payload=self._load_payload(row[1]),
score=_convert_distance_to_score(float(row[2]), distance_metric),
)
for row in rows
]
def _build_filters(self, filters: Optional[Dict[str, Any]]) -> tuple[str, Dict[str, Any]]:
if not filters:
return "", {}
params: Dict[str, Any] = {}
return "WHERE " + _build_filter_group(filters, params), params
def delete(self, vector_id: str) -> None:
"""
Delete a vector by ID.
Args:
vector_id (str): ID of the vector to delete.
"""
with self._get_cursor(commit=True) as cursor:
cursor.execute(f"DELETE FROM {self.collection_name} WHERE id = :id", id=vector_id)
def update(
self,
vector_id: str,
vector: Optional[List[float]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> None:
"""
Update a vector and its payload.
Args:
vector_id (str): ID of the vector to update.
vector (List[float], optional): Updated vector.
payload (Dict, optional): Updated payload.
"""
if vector is None and payload is None:
return
with self._get_cursor(commit=True) as cursor:
sets, params = [], {"vector_id": vector_id}
if vector is not None:
sets.append("vector = :vector")
params["vector"] = array.array("f", vector)
cursor.setinputsizes(vector=oracledb.DB_TYPE_VECTOR)
if payload is not None:
sets.append("payload = :payload")
params["payload"] = payload
cursor.setinputsizes(payload=oracledb.DB_TYPE_JSON)
cursor.execute(f"UPDATE {self.collection_name} SET {', '.join(sets)} WHERE id = :vector_id", params)
def get(self, vector_id: str) -> Optional[OutputData]:
"""
Retrieve a vector by ID.
Args:
vector_id (str): ID of the vector to retrieve.
Returns:
OutputData: Retrieved vector.
"""
with self._get_cursor() as cursor:
cursor.execute(
f"SELECT id, payload FROM {self.collection_name} WHERE id = :vector_id",
vector_id=vector_id,
)
row = cursor.fetchone()
if row is None:
return None
return OutputData(id=row[0], score=None, payload=self._load_payload(row[1]))
def list_cols(self) -> List[str]:
"""
List all collections.
Returns:
List[str]: List of collection names.
"""
with self._get_cursor() as cursor:
cursor.execute("SELECT table_name FROM user_tables")
tables = [row[0] for row in cursor.fetchall()]
return tables
def delete_col(self) -> None:
"""Delete a collection."""
with self._get_cursor(commit=True) as cursor:
cursor.execute(f"DROP TABLE {self.collection_name} PURGE")
def col_info(self) -> Dict[str, Any]:
"""
Get information about a collection.
Returns:
Dict[str, Any]: Collection information.
"""
owner, table_name = self._split_collection_name()
sql = f"""
SELECT
table_name,
(SELECT COUNT(*) FROM {self.collection_name}) AS row_count,
(SELECT
ROUND(SUM(bytes) / 1024 / 1024, 2) || ' MB'
FROM user_segments
WHERE segment_name = :table_name
AND segment_type = 'TABLE'
) AS total_size
FROM all_tables
WHERE table_name = :table_name
AND owner = NVL(:owner, USER)
"""
with self._get_cursor() as cursor:
cursor.execute(sql, table_name=table_name, owner=owner)
result = cursor.fetchone()
if result is None:
raise ValueError(f"Collection {self.collection_name} not found")
return {"name": result[0], "count": result[1], "size": result[2]}
def _split_collection_name(self) -> tuple[Optional[str], str]:
"""Split the quoted collection name into its optional owner and table parts."""
segments = re.findall(r'"([^"]+)"', self.collection_name)
if len(segments) > 1:
return segments[-2], segments[-1]
return None, segments[-1]
def list(self, filters: Optional[Dict[str, Any]] = None, top_k: Optional[int] = 100) -> List[List[OutputData]]:
"""
List all vectors in a collection.
Args:
filters (Dict, optional): Filters to apply to the list.
top_k (int, optional): Number of vectors to return. Defaults to 100.
Returns:
List[List[OutputData]]: A single-element list holding the list of vectors.
"""
filter_clause, params = self._build_filters(filters)
limit_clause = ""
if top_k is not None:
limit_clause = " FETCH FIRST :limit ROWS ONLY"
params["limit"] = top_k
sql = f"SELECT id, payload FROM {self.collection_name} {filter_clause} {limit_clause}"
with self._get_cursor() as cursor:
cursor.execute(sql, **params)
rows = cursor.fetchall()
return [[OutputData(id=row[0], score=None, payload=self._load_payload(row[1])) for row in rows]]
def reset(self) -> None:
"""Reset the index by deleting and recreating it."""
logger.warning("Resetting collection %s", self.collection_name)
self.delete_col()
self.create_col()
def __del__(self) -> None:
"""
Close the database connection pool when the object is deleted.
"""
try:
if getattr(self, "_owns_client", False):
self.client.close()
except Exception:
pass
+4 -4
View File
@@ -459,13 +459,13 @@ class PGVector(VectorStoreBase):
self._ensure_collection()
with self._get_cursor() as cur:
cur.execute(
sql.SQL("SELECT id, vector, payload FROM {} WHERE id = %s").format(self._col()),
sql.SQL("SELECT id, payload FROM {} WHERE id = %s").format(self._col()),
(vector_id,),
)
result = cur.fetchone()
if not result:
return None
return OutputData(id=str(result[0]), score=None, payload=result[2])
return OutputData(id=str(result[0]), score=None, payload=result[1])
def list_cols(self) -> List[str]:
"""
@@ -528,7 +528,7 @@ class PGVector(VectorStoreBase):
with self._get_cursor() as cur:
cur.execute(
sql.SQL("""
SELECT id, vector, payload
SELECT id, payload
FROM {}
{}
LIMIT %s
@@ -536,7 +536,7 @@ class PGVector(VectorStoreBase):
(*filter_params, top_k),
)
results = cur.fetchall()
return [[OutputData(id=str(r[0]), score=None, payload=r[2]) for r in results]]
return [[OutputData(id=str(r[0]), score=None, payload=r[1]) for r in results]]
def __del__(self) -> None:
"""
-14
View File
@@ -1,14 +0,0 @@
*.db
.env*
!.env.example
!.env.dev
!ui/lib
.venv/
__pycache__
.DS_Store
node_modules/
*.log
api/.openmemory*
**/.next
.openmemory/
ui/package-lock.json
-70
View File
@@ -1,70 +0,0 @@
# Contributing to OpenMemory
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
## Ways to Contribute
We welcome all forms of contributions:
- Bug reports and feature requests through GitHub Issues
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support and discussions
## Development Workflow
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
## Development Setup
### Backend Setup
```bash
# Copy environment file and edit file to update OPENAI_API_KEY and other secrets
make env
# Build the containers
make build
# Start the services
make up
```
### Frontend Setup
The frontend is a React application. To start the frontend:
```bash
# Install dependencies and start the development server
make ui-dev
```
### Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (for LLM interactions)
### Getting Started
Follow the setup instructions in the README.md file to set up your development environment.
## Code Standards
We value:
- Clean, well-documented code
- Thoughtful discussions about features and improvements
- Respectful and constructive feedback
- A welcoming environment for all contributors
## Pull Request Process
1. Ensure your code follows the project's coding standards
2. Update documentation as needed
3. Include tests for new features
4. Make sure all tests pass before submitting
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
-52
View File
@@ -1,52 +0,0 @@
.PHONY: help up down logs shell migrate test test-clean env ui-install ui-start ui-dev ui-build ui-dev-start
NEXT_PUBLIC_USER_ID=$(USER)
NEXT_PUBLIC_API_URL=http://localhost:8765
# Default target
help:
@echo "Available commands:"
@echo " make env - Copy .env.example to .env"
@echo " make up - Start the containers"
@echo " make down - Stop the containers"
@echo " make logs - Show container logs"
@echo " make shell - Open a shell in the api container"
@echo " make migrate - Run database migrations"
@echo " make test - Run tests in a new container"
@echo " make test-clean - Run tests and clean up volumes"
@echo " make ui-install - Install frontend dependencies"
@echo " make ui-start - Start the frontend development server"
@echo " make ui-dev - Install dependencies and start the frontend in dev mode"
@echo " make ui - Install dependencies and start the frontend in production mode"
env:
cd api && cp .env.example .env
cd ui && cp .env.example .env
build:
docker compose build
up:
NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) docker compose up
down:
docker compose down -v
rm -f api/openmemory.db
logs:
docker compose logs -f
shell:
docker compose exec api bash
upgrade:
docker compose exec api alembic upgrade head
migrate:
docker compose exec api alembic upgrade head
downgrade:
docker compose exec api alembic downgrade -1
ui-dev:
cd ui && NEXT_PUBLIC_USER_ID=$(USER) NEXT_PUBLIC_API_URL=$(NEXT_PUBLIC_API_URL) pnpm install && pnpm dev
-168
View File
@@ -1,168 +0,0 @@
# OpenMemory
> **⚠️ Sunsetting Notice:** OpenMemory is being sunset. For local self-hosted memory with a dashboard, please use the [Mem0 self-hosted server](https://docs.mem0.ai/open-source/overview) instead. Get started with `cd server && make bootstrap`. See the [self-hosted docs](https://docs.mem0.ai/open-source/setup) for configuration details.
OpenMemory is your personal memory layer for LLMs - private, portable, and open-source. Your memories live locally, giving you complete control over your data. Build AI applications with personalized memories while keeping your data secure.
![OpenMemory](https://github.com/user-attachments/assets/3c701757-ad82-4afa-bfbe-e049c2b4320b)
## Easy Setup
### Prerequisites
- Docker
- OpenAI API Key
You can quickly run OpenMemory by running the following command:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | bash
```
You should set the `OPENAI_API_KEY` as a global environment variable:
```bash
export OPENAI_API_KEY=your_api_key
```
You can also set the `OPENAI_API_KEY` as a parameter to the script:
```bash
curl -sL https://raw.githubusercontent.com/mem0ai/mem0/main/openmemory/run.sh | OPENAI_API_KEY=your_api_key bash
```
## Prerequisites
- Docker and Docker Compose
- Python 3.9+ (for backend development)
- Node.js (for frontend development)
- OpenAI API Key (required for LLM interactions, run `cp api/.env.example api/.env` then change **OPENAI_API_KEY** to yours)
## Quickstart
### 1. Set Up Environment Variables
Before running the project, you need to configure environment variables for both the API and the UI.
You can do this in one of the following ways:
- **Manually**:
Create a `.env` file in each of the following directories:
- `/api/.env`
- `/ui/.env`
- **Using `.env.example` files**:
Copy and rename the example files:
```bash
cp api/.env.example api/.env
cp ui/.env.example ui/.env
```
- **Using Makefile** (if supported):
Run:
```bash
make env
```
- #### Example `/api/.env`
```env
OPENAI_API_KEY=sk-xxx
USER=<user-id> # The User Id you want to associate the memories with
```
- #### LLM Configuration (optional)
By default, OpenMemory uses OpenAI (`gpt-4o-mini`) for the LLM and embedder. You can configure a different provider using these environment variables in `/api/.env`:
| Variable | Description | Default |
|---|---|---|
| `LLM_PROVIDER` | LLM provider (`openai`, `ollama`, `anthropic`, `groq`, `together`, `deepseek`, etc.) | `openai` |
| `LLM_MODEL` | Model name for the LLM provider | `gpt-4o-mini` (OpenAI) / `llama3.1:latest` (Ollama) |
| `LLM_API_KEY` | API key for the LLM provider | `OPENAI_API_KEY` env var |
| `LLM_BASE_URL` | Custom base URL for the LLM API | Provider default |
| `OLLAMA_BASE_URL` | Ollama-specific base URL (takes precedence over `LLM_BASE_URL` for Ollama) | `http://localhost:11434` |
| `EMBEDDER_PROVIDER` | Embedder provider (defaults to `ollama` when LLM is Ollama, otherwise `openai`) | `openai` |
| `EMBEDDER_MODEL` | Model name for the embedder | `text-embedding-3-small` (OpenAI) / `nomic-embed-text` (Ollama) |
| `EMBEDDER_API_KEY` | API key for the embedder provider | `OPENAI_API_KEY` env var |
| `EMBEDDER_BASE_URL` | Custom base URL for the embedder API | Provider default |
**Example: Using Ollama (fully local)**
```env
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:latest
EMBEDDER_PROVIDER=ollama
EMBEDDER_MODEL=nomic-embed-text
OLLAMA_BASE_URL=http://localhost:11434
```
**Example: Using Anthropic**
```env
LLM_PROVIDER=anthropic
LLM_MODEL=claude-sonnet-4-20250514
LLM_API_KEY=sk-ant-xxx
```
- #### Example `/ui/.env`
```env
NEXT_PUBLIC_API_URL=http://localhost:8765
NEXT_PUBLIC_USER_ID=<user-id> # Same as the user id for environment variable in api
```
### 2. Build and Run the Project
You can run the project using the following two commands:
```bash
make build # builds the mcp server and ui
make up # runs openmemory mcp server and ui
```
After running these commands, you will have:
- OpenMemory MCP server running at: http://localhost:8765 (API documentation available at http://localhost:8765/docs)
- OpenMemory UI running at: http://localhost:3000
#### UI not working on `localhost:3000`?
If the UI does not start properly on [http://localhost:3000](http://localhost:3000), try running it manually:
```bash
cd ui
pnpm install
pnpm dev
```
### MCP Client Setup
Use the following one step command to configure OpenMemory Local MCP to a client. The general command format is as follows:
```bash
npx @openmemory/install local http://localhost:8765/mcp/<client-name>/sse/<user-id> --client <client-name>
```
Replace `<client-name>` with the desired client name and `<user-id>` with the value specified in your environment variables.
## Project Structure
- `api/` - Backend APIs + MCP server
- `ui/` - Frontend React application
## Contributing
We are a team of developers passionate about the future of AI and open-source software. With years of experience in both fields, we believe in the power of community-driven development and are excited to build tools that make AI more accessible and personalized.
We welcome all forms of contributions:
- Bug reports and feature requests
- Documentation improvements
- Code contributions
- Testing and feedback
- Community support
How to contribute:
1. Fork the repository
2. Create your feature branch (`git checkout -b openmemory/feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin openmemory/feature/amazing-feature`)
5. Open a Pull Request
Join us in building the future of AI memory management! Your contributions help make OpenMemory better for everyone.
-23
View File
@@ -1,23 +0,0 @@
# Ignore all .env files
**/.env
**/.env.*
# Ignore all database files
**/*.db
**/*.sqlite
**/*.sqlite3
# Ignore logs
**/*.log
# Ignore runtime data
**/node_modules
**/__pycache__
**/.pytest_cache
**/.coverage
**/coverage
# Ignore Docker runtime files
**/.dockerignore
**/Dockerfile
**/docker-compose*.yml
-15
View File
@@ -1,15 +0,0 @@
OPENAI_API_KEY=sk-xxx
USER=user
# LLM Configuration (optional - defaults to openai/gpt-4o-mini)
# LLM_PROVIDER=ollama
# LLM_MODEL=llama3.1:latest
# LLM_API_KEY=
# LLM_BASE_URL=
# OLLAMA_BASE_URL=http://localhost:11434
# Embedder Configuration (optional - defaults to openai/text-embedding-3-small)
# EMBEDDER_PROVIDER=ollama
# EMBEDDER_MODEL=nomic-embed-text
# EMBEDDER_API_KEY=
# EMBEDDER_BASE_URL=
-1
View File
@@ -1 +0,0 @@
3.12
-14
View File
@@ -1,14 +0,0 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.name="mem0/openmemory-mcp"
WORKDIR /usr/src/openmemory
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY config.json .
COPY . .
EXPOSE 8765
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]
-60
View File
@@ -1,60 +0,0 @@
# OpenMemory API
This directory contains the backend API for OpenMemory, built with FastAPI and SQLAlchemy. This also runs the Mem0 MCP Server that you can use with MCP clients to remember things.
## Quick Start with Docker (Recommended)
The easiest way to get started is using Docker. Make sure you have Docker and Docker Compose installed.
1. Build the containers:
```bash
make build
```
2. Create `.env` file:
```bash
make env
```
Once you run this command, edit the file `api/.env` and enter the `OPENAI_API_KEY`.
3. Start the services:
```bash
make up
```
The API will be available at `http://localhost:8765`
### Common Docker Commands
- View logs: `make logs`
- Open shell in container: `make shell`
- Run database migrations: `make migrate`
- Run tests: `make test`
- Run tests and clean up: `make test-clean`
- Stop containers: `make down`
## API Documentation
Once the server is running, you can access the API documentation at:
- Swagger UI: `http://localhost:8765/docs`
- ReDoc: `http://localhost:8765/redoc`
## Project Structure
- `app/`: Main application code
- `models.py`: Database models
- `database.py`: Database configuration
- `routers/`: API route handlers
- `migrations/`: Database migration files
- `tests/`: Test files
- `alembic/`: Alembic migration configuration
- `main.py`: Application entry point
## Development Guidelines
- Follow PEP 8 style guide
- Use type hints
- Write tests for new features
- Update documentation when making changes
- Run migrations for database changes
-114
View File
@@ -1,114 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or colons.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///./openmemory.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-88
View File
@@ -1,88 +0,0 @@
import os
import sys
from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool
# Add the parent directory to the Python path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Load environment variables
load_dotenv()
# Import your models here - moved after path setup
from app.database import Base # noqa: E402
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-28
View File
@@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -1,225 +0,0 @@
"""Initial migration
Revision ID: 0b53c747049a
Revises:
Create Date: 2025-04-19 00:59:56.244203
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '0b53c747049a'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('access_controls',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('subject_type', sa.String(), nullable=False),
sa.Column('subject_id', sa.UUID(), nullable=True),
sa.Column('object_type', sa.String(), nullable=False),
sa.Column('object_id', sa.UUID(), nullable=True),
sa.Column('effect', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_object', 'access_controls', ['object_type', 'object_id'], unique=False)
op.create_index('idx_access_subject', 'access_controls', ['subject_type', 'subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_created_at'), 'access_controls', ['created_at'], unique=False)
op.create_index(op.f('ix_access_controls_effect'), 'access_controls', ['effect'], unique=False)
op.create_index(op.f('ix_access_controls_object_id'), 'access_controls', ['object_id'], unique=False)
op.create_index(op.f('ix_access_controls_object_type'), 'access_controls', ['object_type'], unique=False)
op.create_index(op.f('ix_access_controls_subject_id'), 'access_controls', ['subject_id'], unique=False)
op.create_index(op.f('ix_access_controls_subject_type'), 'access_controls', ['subject_type'], unique=False)
op.create_table('archive_policies',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('criteria_type', sa.String(), nullable=False),
sa.Column('criteria_id', sa.UUID(), nullable=True),
sa.Column('days_to_archive', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_policy_criteria', 'archive_policies', ['criteria_type', 'criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_created_at'), 'archive_policies', ['created_at'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_id'), 'archive_policies', ['criteria_id'], unique=False)
op.create_index(op.f('ix_archive_policies_criteria_type'), 'archive_policies', ['criteria_type'], unique=False)
op.create_table('categories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_categories_created_at'), 'categories', ['created_at'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_created_at'), 'users', ['created_at'], unique=False)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_name'), 'users', ['name'], unique=False)
op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=True)
op.create_table('apps',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_apps_created_at'), 'apps', ['created_at'], unique=False)
op.create_index(op.f('ix_apps_is_active'), 'apps', ['is_active'], unique=False)
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=True)
op.create_index(op.f('ix_apps_owner_id'), 'apps', ['owner_id'], unique=False)
op.create_table('memories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('content', sa.String(), nullable=False),
sa.Column('vector', sa.String(), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('archived_at', sa.DateTime(), nullable=True),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_memory_app_state', 'memories', ['app_id', 'state'], unique=False)
op.create_index('idx_memory_user_app', 'memories', ['user_id', 'app_id'], unique=False)
op.create_index('idx_memory_user_state', 'memories', ['user_id', 'state'], unique=False)
op.create_index(op.f('ix_memories_app_id'), 'memories', ['app_id'], unique=False)
op.create_index(op.f('ix_memories_archived_at'), 'memories', ['archived_at'], unique=False)
op.create_index(op.f('ix_memories_created_at'), 'memories', ['created_at'], unique=False)
op.create_index(op.f('ix_memories_deleted_at'), 'memories', ['deleted_at'], unique=False)
op.create_index(op.f('ix_memories_state'), 'memories', ['state'], unique=False)
op.create_index(op.f('ix_memories_user_id'), 'memories', ['user_id'], unique=False)
op.create_table('memory_access_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('app_id', sa.UUID(), nullable=False),
sa.Column('accessed_at', sa.DateTime(), nullable=True),
sa.Column('access_type', sa.String(), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['app_id'], ['apps.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_access_app_time', 'memory_access_logs', ['app_id', 'accessed_at'], unique=False)
op.create_index('idx_access_memory_time', 'memory_access_logs', ['memory_id', 'accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_access_type'), 'memory_access_logs', ['access_type'], unique=False)
op.create_index(op.f('ix_memory_access_logs_accessed_at'), 'memory_access_logs', ['accessed_at'], unique=False)
op.create_index(op.f('ix_memory_access_logs_app_id'), 'memory_access_logs', ['app_id'], unique=False)
op.create_index(op.f('ix_memory_access_logs_memory_id'), 'memory_access_logs', ['memory_id'], unique=False)
op.create_table('memory_categories',
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('category_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('memory_id', 'category_id')
)
op.create_index('idx_memory_category', 'memory_categories', ['memory_id', 'category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_category_id'), 'memory_categories', ['category_id'], unique=False)
op.create_index(op.f('ix_memory_categories_memory_id'), 'memory_categories', ['memory_id'], unique=False)
op.create_table('memory_status_history',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('memory_id', sa.UUID(), nullable=False),
sa.Column('changed_by', sa.UUID(), nullable=False),
sa.Column('old_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('new_state', sa.Enum('active', 'paused', 'archived', 'deleted', name='memorystate'), nullable=False),
sa.Column('changed_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['memory_id'], ['memories.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_history_memory_state', 'memory_status_history', ['memory_id', 'new_state'], unique=False)
op.create_index('idx_history_user_time', 'memory_status_history', ['changed_by', 'changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_at'), 'memory_status_history', ['changed_at'], unique=False)
op.create_index(op.f('ix_memory_status_history_changed_by'), 'memory_status_history', ['changed_by'], unique=False)
op.create_index(op.f('ix_memory_status_history_memory_id'), 'memory_status_history', ['memory_id'], unique=False)
op.create_index(op.f('ix_memory_status_history_new_state'), 'memory_status_history', ['new_state'], unique=False)
op.create_index(op.f('ix_memory_status_history_old_state'), 'memory_status_history', ['old_state'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_memory_status_history_old_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_new_state'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_memory_id'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_by'), table_name='memory_status_history')
op.drop_index(op.f('ix_memory_status_history_changed_at'), table_name='memory_status_history')
op.drop_index('idx_history_user_time', table_name='memory_status_history')
op.drop_index('idx_history_memory_state', table_name='memory_status_history')
op.drop_table('memory_status_history')
op.drop_index(op.f('ix_memory_categories_memory_id'), table_name='memory_categories')
op.drop_index(op.f('ix_memory_categories_category_id'), table_name='memory_categories')
op.drop_index('idx_memory_category', table_name='memory_categories')
op.drop_table('memory_categories')
op.drop_index(op.f('ix_memory_access_logs_memory_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_app_id'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_accessed_at'), table_name='memory_access_logs')
op.drop_index(op.f('ix_memory_access_logs_access_type'), table_name='memory_access_logs')
op.drop_index('idx_access_memory_time', table_name='memory_access_logs')
op.drop_index('idx_access_app_time', table_name='memory_access_logs')
op.drop_table('memory_access_logs')
op.drop_index(op.f('ix_memories_user_id'), table_name='memories')
op.drop_index(op.f('ix_memories_state'), table_name='memories')
op.drop_index(op.f('ix_memories_deleted_at'), table_name='memories')
op.drop_index(op.f('ix_memories_created_at'), table_name='memories')
op.drop_index(op.f('ix_memories_archived_at'), table_name='memories')
op.drop_index(op.f('ix_memories_app_id'), table_name='memories')
op.drop_index('idx_memory_user_state', table_name='memories')
op.drop_index('idx_memory_user_app', table_name='memories')
op.drop_index('idx_memory_app_state', table_name='memories')
op.drop_table('memories')
op.drop_index(op.f('ix_apps_owner_id'), table_name='apps')
op.drop_index(op.f('ix_apps_name'), table_name='apps')
op.drop_index(op.f('ix_apps_is_active'), table_name='apps')
op.drop_index(op.f('ix_apps_created_at'), table_name='apps')
op.drop_table('apps')
op.drop_index(op.f('ix_users_user_id'), table_name='users')
op.drop_index(op.f('ix_users_name'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_created_at'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_created_at'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_archive_policies_criteria_type'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_criteria_id'), table_name='archive_policies')
op.drop_index(op.f('ix_archive_policies_created_at'), table_name='archive_policies')
op.drop_index('idx_policy_criteria', table_name='archive_policies')
op.drop_table('archive_policies')
op.drop_index(op.f('ix_access_controls_subject_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_subject_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_type'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_object_id'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_effect'), table_name='access_controls')
op.drop_index(op.f('ix_access_controls_created_at'), table_name='access_controls')
op.drop_index('idx_access_subject', table_name='access_controls')
op.drop_index('idx_access_object', table_name='access_controls')
op.drop_table('access_controls')
# ### end Alembic commands ###
@@ -1,40 +0,0 @@
"""add_config_table
Revision ID: add_config_table
Revises: 0b53c747049a
Create Date: 2023-06-01 10:00:00.000000
"""
import uuid
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'add_config_table'
down_revision = '0b53c747049a'
branch_labels = None
depends_on = None
def upgrade():
# Create configs table if it doesn't exist
op.create_table(
'configs',
sa.Column('id', sa.UUID(), nullable=False, default=lambda: uuid.uuid4()),
sa.Column('key', sa.String(), nullable=False),
sa.Column('value', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
# Create index for key lookups
op.create_index('idx_configs_key', 'configs', ['key'])
def downgrade():
# Drop the configs table
op.drop_index('idx_configs_key', 'configs')
op.drop_table('configs')
@@ -1,34 +0,0 @@
"""remove_global_unique_constraint_on_app_name_add_composite_unique
Revision ID: afd00efbd06b
Revises: add_config_table
Create Date: 2025-06-04 01:59:41.637440
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'afd00efbd06b'
down_revision: Union[str, None] = 'add_config_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_apps_name', table_name='apps')
op.create_index(op.f('ix_apps_name'), 'apps', ['name'], unique=False)
op.create_index('idx_app_owner_name', 'apps', ['owner_id', 'name'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_app_owner_name', table_name='apps')
op.drop_index(op.f('ix_apps_name'), table_name='apps')
op.create_index('ix_apps_name', 'apps', ['name'], unique=True)
# ### end Alembic commands ###
-1
View File
@@ -1 +0,0 @@
# This file makes the app directory a Python package
-4
View File
@@ -1,4 +0,0 @@
import os
USER_ID = os.getenv("USER", "default_user")
DEFAULT_APP_ID = "openmemory"
-30
View File
@@ -1,30 +0,0 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
# load .env file (make sure you have DATABASE_URL set)
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./openmemory.db")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL is not set in environment")
# SQLAlchemy engine & session
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} # Needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
# Dependency for FastAPI
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
-574
View File
@@ -1,574 +0,0 @@
"""
MCP Server for OpenMemory with resilient memory client handling.
This module implements an MCP (Model Context Protocol) server that provides
memory operations for OpenMemory. The memory client is initialized lazily
to prevent server crashes when external dependencies (like Ollama) are
unavailable. If the memory client cannot be initialized, the server will
continue running with limited functionality and appropriate error messages.
Key features:
- Lazy memory client initialization
- Graceful error handling for unavailable dependencies
- Fallback to database-only mode when vector store is unavailable
- Proper logging for debugging connection issues
- Environment variable parsing for API keys
"""
import contextvars
import datetime
import json
import logging
import uuid
import anyio
from app.database import SessionLocal
from app.models import Memory, MemoryAccessLog, MemoryState, MemoryStatusHistory
from app.utils.db import get_user_and_app
from app.utils.memory import get_memory_client
from app.utils.permissions import check_memory_access_permissions
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.routing import APIRouter
from mcp.server.fastmcp import FastMCP
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import StreamableHTTPServerTransport
from starlette.responses import Response
# Load environment variables
load_dotenv()
# Initialize MCP
mcp = FastMCP("mem0-mcp-server")
# Don't initialize memory client at import time - do it lazily when needed
def get_memory_client_safe():
"""Get memory client with error handling. Returns None if client cannot be initialized."""
try:
return get_memory_client()
except Exception as e:
logging.warning(f"Failed to get memory client: {e}")
return None
# Context variables for user_id and client_name
user_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("user_id")
client_name_var: contextvars.ContextVar[str] = contextvars.ContextVar("client_name")
# Create a router for MCP endpoints
mcp_router = APIRouter(prefix="/mcp")
# Initialize SSE transport
sse = SseServerTransport("/mcp/messages/")
@mcp.tool(description="Add a new memory. This method is called everytime the user informs anything about themselves, their preferences, or anything that has any relevant information which can be useful in the future conversation. This can also be called when the user asks you to remember something. Set infer to False to store the memory verbatim without LLM fact extraction.")
async def add_memories(text: str, infer: bool = True) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
# Get memory client safely
memory_client = get_memory_client_safe()
if not memory_client:
return "Error: Memory system is currently unavailable. Please try again later."
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Check if app is active
if not app.is_active:
return f"Error: App {app.name} is currently paused on OpenMemory. Cannot create new memories."
response = memory_client.add(text,
user_id=uid,
metadata={
"source_app": "openmemory",
"mcp_client": client_name,
},
infer=infer)
# Process the response and update database
if isinstance(response, dict) and 'results' in response:
for result in response['results']:
memory_id = uuid.UUID(result['id'])
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if result['event'] == 'ADD':
if not memory:
memory = Memory(
id=memory_id,
user_id=user.id,
app_id=app.id,
content=result['memory'],
state=MemoryState.active
)
db.add(memory)
else:
memory.state = MemoryState.active
memory.content = result['memory']
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted if memory else None,
new_state=MemoryState.active
)
db.add(history)
elif result['event'] == 'DELETE':
if memory:
memory.state = MemoryState.deleted
memory.deleted_at = datetime.datetime.now(datetime.UTC)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
db.commit()
return json.dumps(response)
finally:
db.close()
except Exception as e:
logging.exception(f"Error adding to memory: {e}")
return f"Error adding to memory: {e}"
@mcp.tool(description="Search through stored memories. This method is called EVERYTIME the user asks anything.")
async def search_memory(query: str) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
# Get memory client safely
memory_client = get_memory_client_safe()
if not memory_client:
return "Error: Memory system is currently unavailable. Please try again later."
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get accessible memory IDs based on ACL
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
filters = {
"user_id": uid
}
embeddings = memory_client.embedding_model.embed(query, "search")
hits = memory_client.vector_store.search(
query=query,
vectors=embeddings,
limit=10,
filters=filters,
)
allowed = set(str(mid) for mid in accessible_memory_ids) if accessible_memory_ids else None
results = []
for h in hits:
# All vector db search functions return OutputData class
id, score, payload = h.id, h.score, h.payload
if allowed and (h.id is None or h.id not in allowed):
continue
results.append({
"id": id,
"memory": payload.get("data"),
"hash": payload.get("hash"),
"created_at": payload.get("created_at"),
"updated_at": payload.get("updated_at"),
"score": score,
})
for r in results:
if r.get("id"):
access_log = MemoryAccessLog(
memory_id=uuid.UUID(r["id"]),
app_id=app.id,
access_type="search",
metadata_={
"query": query,
"score": r.get("score"),
"hash": r.get("hash"),
},
)
db.add(access_log)
db.commit()
return json.dumps({"results": results}, indent=2)
finally:
db.close()
except Exception as e:
logging.exception(e)
return f"Error searching memory: {e}"
@mcp.tool(description="List all memories in the user's memory")
async def list_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
# Get memory client safely
memory_client = get_memory_client_safe()
if not memory_client:
return "Error: Memory system is currently unavailable. Please try again later."
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Get all memories
memories = memory_client.get_all(user_id=uid)
filtered_memories = []
# Filter memories based on permissions
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
if isinstance(memories, dict) and 'results' in memories:
for memory_data in memories['results']:
if 'id' in memory_data:
memory_id = uuid.UUID(memory_data['id'])
if memory_id in accessible_memory_ids:
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory_data.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory_data)
db.commit()
else:
for memory in memories:
memory_id = uuid.UUID(memory['id'])
memory_obj = db.query(Memory).filter(Memory.id == memory_id).first()
if memory_obj and check_memory_access_permissions(db, memory_obj, app.id):
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="list",
metadata_={
"hash": memory.get('hash')
}
)
db.add(access_log)
filtered_memories.append(memory)
db.commit()
return json.dumps(filtered_memories, indent=2)
finally:
db.close()
except Exception as e:
logging.exception(f"Error getting memories: {e}")
return f"Error getting memories: {e}"
@mcp.tool(description="Delete specific memories by their IDs")
async def delete_memories(memory_ids: list[str]) -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
# Get memory client safely
memory_client = get_memory_client_safe()
if not memory_client:
return "Error: Memory system is currently unavailable. Please try again later."
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
# Convert string IDs to UUIDs and filter accessible ones
requested_ids = [uuid.UUID(mid) for mid in memory_ids]
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
# Only delete memories that are both requested and accessible
ids_to_delete = [mid for mid in requested_ids if mid in accessible_memory_ids]
if not ids_to_delete:
return "Error: No accessible memories found with provided IDs"
# Delete from vector store
for memory_id in ids_to_delete:
try:
memory_client.delete(str(memory_id))
except Exception as delete_error:
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
# Update each memory's state and create history entries
now = datetime.datetime.now(datetime.UTC)
for memory_id in ids_to_delete:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if memory:
# Update memory state
memory.state = MemoryState.deleted
memory.deleted_at = now
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="delete",
metadata_={"operation": "delete_by_id"}
)
db.add(access_log)
db.commit()
return f"Successfully deleted {len(ids_to_delete)} memories"
finally:
db.close()
except Exception as e:
logging.exception(f"Error deleting memories: {e}")
return f"Error deleting memories: {e}"
@mcp.tool(description="Delete all memories in the user's memory")
async def delete_all_memories() -> str:
uid = user_id_var.get(None)
client_name = client_name_var.get(None)
if not uid:
return "Error: user_id not provided"
if not client_name:
return "Error: client_name not provided"
# Get memory client safely
memory_client = get_memory_client_safe()
if not memory_client:
return "Error: Memory system is currently unavailable. Please try again later."
try:
db = SessionLocal()
try:
# Get or create user and app
user, app = get_user_and_app(db, user_id=uid, app_id=client_name)
user_memories = db.query(Memory).filter(Memory.user_id == user.id).all()
accessible_memory_ids = [memory.id for memory in user_memories if check_memory_access_permissions(db, memory, app.id)]
# delete the accessible memories only
for memory_id in accessible_memory_ids:
try:
memory_client.delete(str(memory_id))
except Exception as delete_error:
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
# Update each memory's state and create history entries
now = datetime.datetime.now(datetime.UTC)
for memory_id in accessible_memory_ids:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
# Update memory state
memory.state = MemoryState.deleted
memory.deleted_at = now
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.active,
new_state=MemoryState.deleted
)
db.add(history)
# Create access log entry
access_log = MemoryAccessLog(
memory_id=memory_id,
app_id=app.id,
access_type="delete_all",
metadata_={"operation": "bulk_delete"}
)
db.add(access_log)
db.commit()
return "Successfully deleted all memories"
finally:
db.close()
except Exception as e:
logging.exception(f"Error deleting memories: {e}")
return f"Error deleting memories: {e}"
@mcp_router.get("/{client_name}/sse/{user_id}")
async def handle_sse(request: Request):
"""Handle SSE connections for a specific user and client"""
# Extract user_id and client_name from path parameters
uid = request.path_params.get("user_id")
user_token = user_id_var.set(uid or "")
client_name = request.path_params.get("client_name")
client_token = client_name_var.set(client_name or "")
try:
# NOTE: request._send is the raw ASGI `send` callable. Starlette does not
# expose it publicly, but the MCP SDK transports require the raw ASGI
# interface (scope, receive, send). This is the standard pattern from the
# MCP Python SDK examples.
async with sse.connect_sse(
request.scope,
request.receive,
request._send,
) as (read_stream, write_stream):
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options(),
)
finally:
# Clean up context variables
user_id_var.reset(user_token)
client_name_var.reset(client_token)
@mcp_router.post("/messages/")
async def handle_get_message(request: Request):
return await handle_post_message(request)
@mcp_router.post("/{client_name}/sse/{user_id}/messages/")
async def handle_post_message(request: Request):
return await handle_post_message(request)
async def handle_post_message(request: Request):
"""Handle POST messages for SSE"""
try:
body = await request.body()
# Create a simple receive function that returns the body
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
# Create a simple send function that does nothing
async def send(message):
return {}
# Call handle_post_message with the correct arguments
await sse.handle_post_message(request.scope, receive, send)
# Return a success response
return {"status": "ok"}
finally:
pass
@mcp_router.api_route("/{client_name}/http/{user_id}", methods=["POST", "GET", "DELETE"])
async def handle_streamable_http(request: Request):
"""Handle Streamable HTTP connections for a specific user and client.
Uses the Streamable HTTP transport (MCP spec 2025-03-26+) which replaces
the deprecated SSE transport. Runs in stateless mode — each request is
handled independently with no persistent session.
The transport writes its response directly to the ASGI ``send`` callable.
We intercept it via ``capture_send`` so we can return a proper ``Response``
to FastAPI — otherwise FastAPI would also try to send its own response,
causing a "double-response" bug.
"""
uid = request.path_params.get("user_id")
user_token = user_id_var.set(uid or "")
client_name = request.path_params.get("client_name")
client_token = client_name_var.set(client_name or "")
# Intercept the ASGI messages the transport sends so we can return them
# as a single Response to FastAPI. Without this, FastAPI would attempt to
# write its own response after the transport already wrote one.
response_started = False
response_status = 200
response_headers: list[tuple[bytes, bytes]] = []
response_body = bytearray()
async def capture_send(message):
nonlocal response_started, response_status
if message["type"] == "http.response.start":
response_started = True
response_status = message["status"]
response_headers.extend(message.get("headers", []))
elif message["type"] == "http.response.body":
response_body.extend(message.get("body", b""))
try:
transport = StreamableHTTPServerTransport(
mcp_session_id=None,
is_json_response_enabled=True,
)
async with anyio.create_task_group() as tg:
async def run_server(*, task_status=anyio.TASK_STATUS_IGNORED):
async with transport.connect() as (read_stream, write_stream):
task_status.started()
await mcp._mcp_server.run(
read_stream,
write_stream,
mcp._mcp_server.create_initialization_options(),
stateless=True,
)
await tg.start(run_server)
await transport.handle_request(request.scope, request.receive, capture_send)
await transport.terminate()
tg.cancel_scope.cancel()
finally:
user_id_var.reset(user_token)
client_name_var.reset(client_token)
if not response_started:
return Response(status_code=500, content=b"Transport did not produce a response")
# Header dict conversion is safe here: the MCP transport in stateless JSON
# mode only emits single-valued headers (Content-Type, Content-Length).
return Response(
content=bytes(response_body),
status_code=response_status,
headers={k.decode(): v.decode() for k, v in response_headers},
)
def setup_mcp_server(app: FastAPI):
"""Setup MCP server with the FastAPI application"""
mcp._mcp_server.name = "mem0-mcp-server"
# Include MCP router in the FastAPI app
app.include_router(mcp_router)
-243
View File
@@ -1,243 +0,0 @@
import datetime
import enum
import uuid
import sqlalchemy as sa
from app.database import Base
from app.utils.categorization import get_categories_for_memory
from sqlalchemy import (
JSON,
UUID,
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Table,
event,
)
from sqlalchemy.orm import Session, relationship
def get_current_utc_time():
"""Get current UTC time"""
return datetime.datetime.now(datetime.UTC)
class MemoryState(enum.Enum):
active = "active"
paused = "paused"
archived = "archived"
deleted = "deleted"
class User(Base):
__tablename__ = "users"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(String, nullable=False, unique=True, index=True)
name = Column(String, nullable=True, index=True)
email = Column(String, unique=True, nullable=True, index=True)
metadata_ = Column('metadata', JSON, default=dict)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
apps = relationship("App", back_populates="owner")
memories = relationship("Memory", back_populates="user")
class App(Base):
__tablename__ = "apps"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
owner_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String, nullable=False, index=True)
description = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
owner = relationship("User", back_populates="apps")
memories = relationship("Memory", back_populates="app")
__table_args__ = (
sa.UniqueConstraint('owner_id', 'name', name='idx_app_owner_name'),
)
class Config(Base):
__tablename__ = "configs"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
key = Column(String, unique=True, nullable=False, index=True)
value = Column(JSON, nullable=False)
created_at = Column(DateTime, default=get_current_utc_time)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
class Memory(Base):
__tablename__ = "memories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
user_id = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
content = Column(String, nullable=False)
vector = Column(String)
metadata_ = Column('metadata', JSON, default=dict)
state = Column(Enum(MemoryState), default=MemoryState.active, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
archived_at = Column(DateTime, nullable=True, index=True)
deleted_at = Column(DateTime, nullable=True, index=True)
user = relationship("User", back_populates="memories")
app = relationship("App", back_populates="memories")
categories = relationship("Category", secondary="memory_categories", back_populates="memories")
__table_args__ = (
Index('idx_memory_user_state', 'user_id', 'state'),
Index('idx_memory_app_state', 'app_id', 'state'),
Index('idx_memory_user_app', 'user_id', 'app_id'),
)
class Category(Base):
__tablename__ = "categories"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
name = Column(String, unique=True, nullable=False, index=True)
description = Column(String)
created_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC), index=True)
updated_at = Column(DateTime,
default=get_current_utc_time,
onupdate=get_current_utc_time)
memories = relationship("Memory", secondary="memory_categories", back_populates="categories")
memory_categories = Table(
"memory_categories", Base.metadata,
Column("memory_id", UUID, ForeignKey("memories.id"), primary_key=True, index=True),
Column("category_id", UUID, ForeignKey("categories.id"), primary_key=True, index=True),
Index('idx_memory_category', 'memory_id', 'category_id')
)
class AccessControl(Base):
__tablename__ = "access_controls"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
subject_type = Column(String, nullable=False, index=True)
subject_id = Column(UUID, nullable=True, index=True)
object_type = Column(String, nullable=False, index=True)
object_id = Column(UUID, nullable=True, index=True)
effect = Column(String, nullable=False, index=True)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_access_subject', 'subject_type', 'subject_id'),
Index('idx_access_object', 'object_type', 'object_id'),
)
class ArchivePolicy(Base):
__tablename__ = "archive_policies"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
criteria_type = Column(String, nullable=False, index=True)
criteria_id = Column(UUID, nullable=True, index=True)
days_to_archive = Column(Integer, nullable=False)
created_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_policy_criteria', 'criteria_type', 'criteria_id'),
)
class MemoryStatusHistory(Base):
__tablename__ = "memory_status_history"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
changed_by = Column(UUID, ForeignKey("users.id"), nullable=False, index=True)
old_state = Column(Enum(MemoryState), nullable=False, index=True)
new_state = Column(Enum(MemoryState), nullable=False, index=True)
changed_at = Column(DateTime, default=get_current_utc_time, index=True)
__table_args__ = (
Index('idx_history_memory_state', 'memory_id', 'new_state'),
Index('idx_history_user_time', 'changed_by', 'changed_at'),
)
class MemoryAccessLog(Base):
__tablename__ = "memory_access_logs"
id = Column(UUID, primary_key=True, default=lambda: uuid.uuid4())
memory_id = Column(UUID, ForeignKey("memories.id"), nullable=False, index=True)
app_id = Column(UUID, ForeignKey("apps.id"), nullable=False, index=True)
accessed_at = Column(DateTime, default=get_current_utc_time, index=True)
access_type = Column(String, nullable=False, index=True)
metadata_ = Column('metadata', JSON, default=dict)
__table_args__ = (
Index('idx_access_memory_time', 'memory_id', 'accessed_at'),
Index('idx_access_app_time', 'app_id', 'accessed_at'),
)
def categorize_memory(memory: Memory, db: Session) -> None:
"""Categorize a memory using OpenAI and store the categories in the database."""
try:
# Get categories from OpenAI
categories = get_categories_for_memory(memory.content)
# Get or create categories in the database
for category_name in categories:
category = db.query(Category).filter(Category.name == category_name).first()
if not category:
category = Category(
name=category_name,
description=f"Automatically created category for {category_name}"
)
db.add(category)
db.flush() # Flush to get the category ID
# Check if the memory-category association already exists
existing = db.execute(
memory_categories.select().where(
(memory_categories.c.memory_id == memory.id) &
(memory_categories.c.category_id == category.id)
)
).first()
if not existing:
# Create the association
db.execute(
memory_categories.insert().values(
memory_id=memory.id,
category_id=category.id
)
)
db.commit()
except Exception as e:
db.rollback()
print(f"Error categorizing memory: {e}")
@event.listens_for(Memory, 'after_insert')
def after_memory_insert(mapper, connection, target):
"""Trigger categorization after a memory is inserted."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()
@event.listens_for(Memory, 'after_update')
def after_memory_update(mapper, connection, target):
"""Trigger categorization after a memory is updated."""
db = Session(bind=connection)
categorize_memory(target, db)
db.close()
-7
View File
@@ -1,7 +0,0 @@
from .apps import router as apps_router
from .backup import router as backup_router
from .config import router as config_router
from .memories import router as memories_router
from .stats import router as stats_router
__all__ = ["memories_router", "apps_router", "stats_router", "config_router", "backup_router"]
-223
View File
@@ -1,223 +0,0 @@
from typing import Optional
from uuid import UUID
from app.database import get_db
from app.models import App, Memory, MemoryAccessLog, MemoryState
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import desc, func
from sqlalchemy.orm import Session, joinedload
router = APIRouter(prefix="/api/v1/apps", tags=["apps"])
# Helper functions
def get_app_or_404(db: Session, app_id: UUID) -> App:
app = db.query(App).filter(App.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="App not found")
return app
# List all apps with filtering
@router.get("/")
async def list_apps(
name: Optional[str] = None,
is_active: Optional[bool] = None,
sort_by: str = 'name',
sort_direction: str = 'asc',
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Create a subquery for memory counts
memory_counts = db.query(
Memory.app_id,
func.count(Memory.id).label('memory_count')
).filter(
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
).group_by(Memory.app_id).subquery()
# Create a subquery for access counts
access_counts = db.query(
MemoryAccessLog.app_id,
func.count(func.distinct(MemoryAccessLog.memory_id)).label('access_count')
).group_by(MemoryAccessLog.app_id).subquery()
# Base query
query = db.query(
App,
func.coalesce(memory_counts.c.memory_count, 0).label('total_memories_created'),
func.coalesce(access_counts.c.access_count, 0).label('total_memories_accessed')
)
# Join with subqueries
query = query.outerjoin(
memory_counts,
App.id == memory_counts.c.app_id
).outerjoin(
access_counts,
App.id == access_counts.c.app_id
)
if name:
query = query.filter(App.name.ilike(f"%{name}%"))
if is_active is not None:
query = query.filter(App.is_active == is_active)
# Apply sorting
if sort_by == 'name':
sort_field = App.name
elif sort_by == 'memories':
sort_field = func.coalesce(memory_counts.c.memory_count, 0)
elif sort_by == 'memories_accessed':
sort_field = func.coalesce(access_counts.c.access_count, 0)
else:
sort_field = App.name # default sort
if sort_direction == 'desc':
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
total = query.count()
apps = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"apps": [
{
"id": app[0].id,
"name": app[0].name,
"is_active": app[0].is_active,
"total_memories_created": app[1],
"total_memories_accessed": app[2]
}
for app in apps
]
}
# Get app details
@router.get("/{app_id}")
async def get_app_details(
app_id: UUID,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
# Get memory access statistics
access_stats = db.query(
func.count(MemoryAccessLog.id).label("total_memories_accessed"),
func.min(MemoryAccessLog.accessed_at).label("first_accessed"),
func.max(MemoryAccessLog.accessed_at).label("last_accessed")
).filter(MemoryAccessLog.app_id == app_id).first()
return {
"is_active": app.is_active,
"total_memories_created": db.query(Memory)
.filter(Memory.app_id == app_id)
.count(),
"total_memories_accessed": access_stats.total_memories_accessed or 0,
"first_accessed": access_stats.first_accessed,
"last_accessed": access_stats.last_accessed
}
# List memories created by app
@router.get("/{app_id}/memories")
async def list_app_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
get_app_or_404(db, app_id)
query = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.state.in_([MemoryState.active, MemoryState.paused, MemoryState.archived])
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
memories = query.order_by(Memory.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
for memory in memories
]
}
# List memories accessed by app
@router.get("/{app_id}/accessed")
async def list_app_accessed_memories(
app_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
# Get memories with access counts
query = db.query(
Memory,
func.count(MemoryAccessLog.id).label("access_count")
).join(
MemoryAccessLog,
Memory.id == MemoryAccessLog.memory_id
).filter(
MemoryAccessLog.app_id == app_id
).group_by(
Memory.id
).order_by(
desc("access_count")
)
# Add eager loading for categories
query = query.options(joinedload(Memory.categories))
total = query.count()
results = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"memories": [
{
"memory": {
"id": memory.id,
"content": memory.content,
"created_at": memory.created_at,
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
},
"access_count": count
}
for memory, count in results
]
}
@router.put("/{app_id}")
async def update_app_details(
app_id: UUID,
is_active: bool,
db: Session = Depends(get_db)
):
app = get_app_or_404(db, app_id)
app.is_active = is_active
db.commit()
return {"status": "success", "message": "Updated app details successfully"}
-499
View File
@@ -1,499 +0,0 @@
from datetime import UTC, datetime
import io
import json
import gzip
import zipfile
from typing import Optional, List, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_
from app.database import get_db
from app.models import (
User, App, Memory, MemoryState, Category, memory_categories,
MemoryStatusHistory, AccessControl
)
from app.utils.memory import get_memory_client
from uuid import uuid4
router = APIRouter(prefix="/api/v1/backup", tags=["backup"])
class ExportRequest(BaseModel):
user_id: str
app_id: Optional[UUID] = None
from_date: Optional[int] = None
to_date: Optional[int] = None
include_vectors: bool = True
def _iso(dt: Optional[datetime]) -> Optional[str]:
if isinstance(dt, datetime):
try:
return dt.astimezone(UTC).isoformat()
except:
return dt.replace(tzinfo=UTC).isoformat()
return None
def _parse_iso(dt: Optional[str]) -> Optional[datetime]:
if not dt:
return None
try:
return datetime.fromisoformat(dt)
except Exception:
try:
return datetime.fromisoformat(dt.replace("Z", "+00:00"))
except Exception:
return None
def _export_sqlite(db: Session, req: ExportRequest) -> Dict[str, Any]:
user = db.query(User).filter(User.user_id == req.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
time_filters = []
if req.from_date:
time_filters.append(Memory.created_at >= datetime.fromtimestamp(req.from_date, tz=UTC))
if req.to_date:
time_filters.append(Memory.created_at <= datetime.fromtimestamp(req.to_date, tz=UTC))
mem_q = (
db.query(Memory)
.options(joinedload(Memory.categories), joinedload(Memory.app))
.filter(
Memory.user_id == user.id,
*(time_filters or []),
* ( [Memory.app_id == req.app_id] if req.app_id else [] ),
)
)
memories = mem_q.all()
memory_ids = [m.id for m in memories]
app_ids = sorted({m.app_id for m in memories if m.app_id})
apps = db.query(App).filter(App.id.in_(app_ids)).all() if app_ids else []
cats = sorted({c for m in memories for c in m.categories}, key = lambda c: str(c.id))
mc_rows = db.execute(
memory_categories.select().where(memory_categories.c.memory_id.in_(memory_ids))
).fetchall() if memory_ids else []
history = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.memory_id.in_(memory_ids)).all() if memory_ids else []
acls = db.query(AccessControl).filter(
AccessControl.subject_type == "app",
AccessControl.subject_id.in_(app_ids) if app_ids else False
).all() if app_ids else []
return {
"user": {
"id": str(user.id),
"user_id": user.user_id,
"name": user.name,
"email": user.email,
"metadata": user.metadata_,
"created_at": _iso(user.created_at),
"updated_at": _iso(user.updated_at)
},
"apps": [
{
"id": str(a.id),
"owner_id": str(a.owner_id),
"name": a.name,
"description": a.description,
"metadata": a.metadata_,
"is_active": a.is_active,
"created_at": _iso(a.created_at),
"updated_at": _iso(a.updated_at),
}
for a in apps
],
"categories": [
{
"id": str(c.id),
"name": c.name,
"description": c.description,
"created_at": _iso(c.created_at),
"updated_at": _iso(c.updated_at),
}
for c in cats
],
"memories": [
{
"id": str(m.id),
"user_id": str(m.user_id),
"app_id": str(m.app_id) if m.app_id else None,
"content": m.content,
"metadata": m.metadata_,
"state": m.state.value,
"created_at": _iso(m.created_at),
"updated_at": _iso(m.updated_at),
"archived_at": _iso(m.archived_at),
"deleted_at": _iso(m.deleted_at),
"category_ids": [str(c.id) for c in m.categories], #TODO: figure out a way to add category names simply to this
}
for m in memories
],
"memory_categories": [
{"memory_id": str(r.memory_id), "category_id": str(r.category_id)}
for r in mc_rows
],
"status_history": [
{
"id": str(h.id),
"memory_id": str(h.memory_id),
"changed_by": str(h.changed_by),
"old_state": h.old_state.value,
"new_state": h.new_state.value,
"changed_at": _iso(h.changed_at),
}
for h in history
],
"access_controls": [
{
"id": str(ac.id),
"subject_type": ac.subject_type,
"subject_id": str(ac.subject_id) if ac.subject_id else None,
"object_type": ac.object_type,
"object_id": str(ac.object_id) if ac.object_id else None,
"effect": ac.effect,
"created_at": _iso(ac.created_at),
}
for ac in acls
],
"export_meta": {
"app_id_filter": str(req.app_id) if req.app_id else None,
"from_date": req.from_date,
"to_date": req.to_date,
"version": "1",
"generated_at": datetime.now(UTC).isoformat(),
},
}
def _export_logical_memories_gz(
db: Session,
*,
user_id: str,
app_id: Optional[UUID] = None,
from_date: Optional[int] = None,
to_date: Optional[int] = None
) -> bytes:
"""
Export a provider-agnostic backup of memories so they can be restored to any vector DB
by re-embedding content. One JSON object per line, gzip-compressed.
Schema (per line):
{
"id": "<uuid>",
"content": "<text>",
"metadata": {...},
"created_at": "<iso8601 or null>",
"updated_at": "<iso8601 or null>",
"state": "active|paused|archived|deleted",
"app": "<app name or null>",
"categories": ["catA", "catB", ...]
}
"""
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
time_filters = []
if from_date:
time_filters.append(Memory.created_at >= datetime.fromtimestamp(from_date, tz=UTC))
if to_date:
time_filters.append(Memory.created_at <= datetime.fromtimestamp(to_date, tz=UTC))
q = (
db.query(Memory)
.options(joinedload(Memory.categories), joinedload(Memory.app))
.filter(
Memory.user_id == user.id,
*(time_filters or []),
)
)
if app_id:
q = q.filter(Memory.app_id == app_id)
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
for m in q.all():
record = {
"id": str(m.id),
"content": m.content,
"metadata": m.metadata_ or {},
"created_at": _iso(m.created_at),
"updated_at": _iso(m.updated_at),
"state": m.state.value,
"app": m.app.name if m.app else None,
"categories": [c.name for c in m.categories],
}
gz.write((json.dumps(record) + "\n").encode("utf-8"))
return buf.getvalue()
@router.post("/export")
async def export_backup(req: ExportRequest, db: Session = Depends(get_db)):
sqlite_payload = _export_sqlite(db=db, req=req)
memories_blob = _export_logical_memories_gz(
db=db,
user_id=req.user_id,
app_id=req.app_id,
from_date=req.from_date,
to_date=req.to_date,
)
#TODO: add vector store specific exports in future for speed
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("memories.json", json.dumps(sqlite_payload, indent=2))
zf.writestr("memories.jsonl.gz", memories_blob)
zip_buf.seek(0)
return StreamingResponse(
zip_buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="memories_export_{req.user_id}.zip"'},
)
@router.post("/import")
async def import_backup(
file: UploadFile = File(..., description="Zip with memories.json and memories.jsonl.gz"),
user_id: str = Form(..., description="Import memories into this user_id"),
mode: str = Query("overwrite"),
db: Session = Depends(get_db)
):
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Expected a zip file.")
if mode not in {"skip", "overwrite"}:
raise HTTPException(status_code=400, detail="Invalid mode. Must be 'skip' or 'overwrite'.")
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
content = await file.read()
try:
with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
names = zf.namelist()
def find_member(filename: str) -> Optional[str]:
for name in names:
# Skip directory entries
if name.endswith('/'):
continue
if name.rsplit('/', 1)[-1] == filename:
return name
return None
sqlite_member = find_member("memories.json")
if not sqlite_member:
raise HTTPException(status_code=400, detail="memories.json missing in zip")
memories_member = find_member("memories.jsonl.gz")
sqlite_data = json.loads(zf.read(sqlite_member))
memories_blob = zf.read(memories_member) if memories_member else None
except Exception:
raise HTTPException(status_code=400, detail="Invalid zip file")
default_app = db.query(App).filter(App.owner_id == user.id, App.name == "openmemory").first()
if not default_app:
default_app = App(owner_id=user.id, name="openmemory", is_active=True, metadata_={})
db.add(default_app)
db.commit()
db.refresh(default_app)
cat_id_map: Dict[str, UUID] = {}
for c in sqlite_data.get("categories", []):
cat = db.query(Category).filter(Category.name == c["name"]).first()
if not cat:
cat = Category(name=c["name"], description=c.get("description"))
db.add(cat)
db.commit()
db.refresh(cat)
cat_id_map[c["id"]] = cat.id
old_to_new_id: Dict[str, UUID] = {}
for m in sqlite_data.get("memories", []):
incoming_id = UUID(m["id"])
existing = db.query(Memory).filter(Memory.id == incoming_id).first()
# Cross-user collision: always mint a new UUID and import as a new memory
if existing and existing.user_id != user.id:
target_id = uuid4()
else:
target_id = incoming_id
old_to_new_id[m["id"]] = target_id
# Same-user collision + skip mode: leave existing row untouched
if existing and (existing.user_id == user.id) and mode == "skip":
continue
# Same-user collision + overwrite mode: treat import as ground truth
if existing and (existing.user_id == user.id) and mode == "overwrite":
incoming_state = m.get("state", "active")
existing.user_id = user.id
existing.app_id = default_app.id
existing.content = m.get("content") or ""
existing.metadata_ = m.get("metadata") or {}
try:
existing.state = MemoryState(incoming_state)
except Exception:
existing.state = MemoryState.active
# Update state-related timestamps from import (ground truth)
existing.archived_at = _parse_iso(m.get("archived_at"))
existing.deleted_at = _parse_iso(m.get("deleted_at"))
existing.created_at = _parse_iso(m.get("created_at")) or existing.created_at
existing.updated_at = _parse_iso(m.get("updated_at")) or existing.updated_at
db.add(existing)
db.commit()
continue
new_mem = Memory(
id=target_id,
user_id=user.id,
app_id=default_app.id,
content=m.get("content") or "",
metadata_=m.get("metadata") or {},
state=MemoryState(m.get("state", "active")) if m.get("state") else MemoryState.active,
created_at=_parse_iso(m.get("created_at")) or datetime.now(UTC),
updated_at=_parse_iso(m.get("updated_at")) or datetime.now(UTC),
archived_at=_parse_iso(m.get("archived_at")),
deleted_at=_parse_iso(m.get("deleted_at")),
)
db.add(new_mem)
db.commit()
for link in sqlite_data.get("memory_categories", []):
mid = old_to_new_id.get(link["memory_id"])
cid = cat_id_map.get(link["category_id"])
if not (mid and cid):
continue
exists = db.execute(
memory_categories.select().where(
(memory_categories.c.memory_id == mid) & (memory_categories.c.category_id == cid)
)
).first()
if not exists:
db.execute(memory_categories.insert().values(memory_id=mid, category_id=cid))
db.commit()
for h in sqlite_data.get("status_history", []):
hid = UUID(h["id"])
mem_id = old_to_new_id.get(h["memory_id"], UUID(h["memory_id"]))
exists = db.query(MemoryStatusHistory).filter(MemoryStatusHistory.id == hid).first()
if exists and mode == "skip":
continue
rec = exists if exists else MemoryStatusHistory(id=hid)
rec.memory_id = mem_id
rec.changed_by = user.id
try:
rec.old_state = MemoryState(h.get("old_state", "active"))
rec.new_state = MemoryState(h.get("new_state", "active"))
except Exception:
rec.old_state = MemoryState.active
rec.new_state = MemoryState.active
rec.changed_at = _parse_iso(h.get("changed_at")) or datetime.now(UTC)
db.add(rec)
db.commit()
memory_client = get_memory_client()
vector_store = getattr(memory_client, "vector_store", None) if memory_client else None
if vector_store and memory_client and hasattr(memory_client, "embedding_model"):
def iter_logical_records():
if memories_blob:
gz_buf = io.BytesIO(memories_blob)
with gzip.GzipFile(fileobj=gz_buf, mode="rb") as gz:
for raw in gz:
yield json.loads(raw.decode("utf-8"))
else:
for m in sqlite_data.get("memories", []):
yield {
"id": m["id"],
"content": m.get("content"),
"metadata": m.get("metadata") or {},
"created_at": m.get("created_at"),
"updated_at": m.get("updated_at"),
}
for rec in iter_logical_records():
old_id = rec["id"]
new_id = old_to_new_id.get(old_id, UUID(old_id))
content = rec.get("content") or ""
metadata = rec.get("metadata") or {}
created_at = rec.get("created_at")
updated_at = rec.get("updated_at")
if mode == "skip":
try:
get_fn = getattr(vector_store, "get", None)
if callable(get_fn) and vector_store.get(str(new_id)):
continue
except Exception:
pass
payload = dict(metadata)
payload["data"] = content
if created_at:
payload["created_at"] = created_at
if updated_at:
payload["updated_at"] = updated_at
payload["user_id"] = user_id
payload.setdefault("source_app", "openmemory")
try:
vec = memory_client.embedding_model.embed(content, "add")
vector_store.insert(vectors=[vec], payloads=[payload], ids=[str(new_id)])
except Exception as e:
print(f"Vector upsert failed for memory {new_id}: {e}")
continue
return {"message": f'Import completed into user "{user_id}"'}
return {"message": f'Import completed into user "{user_id}"'}
-291
View File
@@ -1,291 +0,0 @@
from typing import Any, Dict, Optional
from app.database import get_db
from app.models import Config as ConfigModel
from app.utils.memory import reset_memory_client
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/v1/config", tags=["config"])
class LLMConfig(BaseModel):
model: str = Field(..., description="LLM model name")
temperature: float = Field(..., description="Temperature setting for the model")
max_tokens: int = Field(..., description="Maximum tokens to generate")
api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable")
ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)")
class LLMProvider(BaseModel):
provider: str = Field(..., description="LLM provider name")
config: LLMConfig
class EmbedderConfig(BaseModel):
model: str = Field(..., description="Embedder model name")
api_key: Optional[str] = Field(None, description="API key or 'env:API_KEY' to use environment variable")
ollama_base_url: Optional[str] = Field(None, description="Base URL for Ollama server (e.g., http://host.docker.internal:11434)")
class EmbedderProvider(BaseModel):
provider: str = Field(..., description="Embedder provider name")
config: EmbedderConfig
class VectorStoreProvider(BaseModel):
provider: str = Field(..., description="Vector store provider name")
# Below config can vary widely based on the vector store used. Refer https://docs.mem0.ai/components/vectordbs/config
config: Dict[str, Any] = Field(..., description="Vector store-specific configuration")
class OpenMemoryConfig(BaseModel):
custom_instructions: Optional[str] = Field(None, description="Custom instructions for memory management and fact extraction")
class Mem0Config(BaseModel):
llm: Optional[LLMProvider] = None
embedder: Optional[EmbedderProvider] = None
vector_store: Optional[VectorStoreProvider] = None
class ConfigSchema(BaseModel):
openmemory: Optional[OpenMemoryConfig] = None
mem0: Optional[Mem0Config] = None
def get_default_configuration():
"""Get the default configuration with sensible defaults for LLM and embedder."""
return {
"openmemory": {
"custom_instructions": None
},
"mem0": {
"llm": {
"provider": "openai",
"config": {
"model": "gpt-4o-mini",
"temperature": 0.1,
"max_tokens": 2000,
"api_key": "env:OPENAI_API_KEY"
}
},
"embedder": {
"provider": "openai",
"config": {
"model": "text-embedding-3-small",
"api_key": "env:OPENAI_API_KEY"
}
},
"vector_store": None
}
}
def get_config_from_db(db: Session, key: str = "main"):
"""Get configuration from database."""
config = db.query(ConfigModel).filter(ConfigModel.key == key).first()
if not config:
# Create default config with proper provider configurations
default_config = get_default_configuration()
db_config = ConfigModel(key=key, value=default_config)
db.add(db_config)
db.commit()
db.refresh(db_config)
return default_config
# Ensure the config has all required sections with defaults
config_value = config.value
default_config = get_default_configuration()
# Merge with defaults to ensure all required fields exist
if "openmemory" not in config_value:
config_value["openmemory"] = default_config["openmemory"]
if "mem0" not in config_value:
config_value["mem0"] = default_config["mem0"]
else:
# Ensure LLM config exists with defaults
if "llm" not in config_value["mem0"] or config_value["mem0"]["llm"] is None:
config_value["mem0"]["llm"] = default_config["mem0"]["llm"]
# Ensure embedder config exists with defaults
if "embedder" not in config_value["mem0"] or config_value["mem0"]["embedder"] is None:
config_value["mem0"]["embedder"] = default_config["mem0"]["embedder"]
# Ensure vector_store config exists with defaults
if "vector_store" not in config_value["mem0"]:
config_value["mem0"]["vector_store"] = default_config["mem0"]["vector_store"]
# Save the updated config back to database if it was modified
if config_value != config.value:
config.value = config_value
db.commit()
db.refresh(config)
return config_value
def save_config_to_db(db: Session, config: Dict[str, Any], key: str = "main"):
"""Save configuration to database."""
db_config = db.query(ConfigModel).filter(ConfigModel.key == key).first()
if db_config:
db_config.value = config
db_config.updated_at = None # Will trigger the onupdate to set current time
else:
db_config = ConfigModel(key=key, value=config)
db.add(db_config)
db.commit()
db.refresh(db_config)
return db_config.value
@router.get("/", response_model=ConfigSchema)
async def get_configuration(db: Session = Depends(get_db)):
"""Get the current configuration."""
config = get_config_from_db(db)
return config
@router.put("/", response_model=ConfigSchema)
async def update_configuration(config: ConfigSchema, db: Session = Depends(get_db)):
"""Update the configuration."""
current_config = get_config_from_db(db)
# Convert to dict for processing
updated_config = current_config.copy()
# Update openmemory settings if provided
if config.openmemory is not None:
if "openmemory" not in updated_config:
updated_config["openmemory"] = {}
updated_config["openmemory"].update(config.openmemory.dict(exclude_none=True))
# Update mem0 settings
updated_config["mem0"] = config.mem0.dict(exclude_none=True)
@router.patch("/", response_model=ConfigSchema)
async def patch_configuration(config_update: ConfigSchema, db: Session = Depends(get_db)):
"""Update parts of the configuration."""
current_config = get_config_from_db(db)
def deep_update(source, overrides):
for key, value in overrides.items():
if isinstance(value, dict) and key in source and isinstance(source[key], dict):
source[key] = deep_update(source[key], value)
else:
source[key] = value
return source
update_data = config_update.dict(exclude_unset=True)
updated_config = deep_update(current_config, update_data)
save_config_to_db(db, updated_config)
reset_memory_client()
return updated_config
@router.post("/reset", response_model=ConfigSchema)
async def reset_configuration(db: Session = Depends(get_db)):
"""Reset the configuration to default values."""
try:
# Get the default configuration with proper provider setups
default_config = get_default_configuration()
# Save it as the current configuration in the database
save_config_to_db(db, default_config)
reset_memory_client()
return default_config
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to reset configuration: {str(e)}"
)
@router.get("/mem0/llm", response_model=LLMProvider)
async def get_llm_configuration(db: Session = Depends(get_db)):
"""Get only the LLM configuration."""
config = get_config_from_db(db)
llm_config = config.get("mem0", {}).get("llm", {})
return llm_config
@router.put("/mem0/llm", response_model=LLMProvider)
async def update_llm_configuration(llm_config: LLMProvider, db: Session = Depends(get_db)):
"""Update only the LLM configuration."""
current_config = get_config_from_db(db)
# Ensure mem0 key exists
if "mem0" not in current_config:
current_config["mem0"] = {}
# Update the LLM configuration
current_config["mem0"]["llm"] = llm_config.dict(exclude_none=True)
# Save the configuration to database
save_config_to_db(db, current_config)
reset_memory_client()
return current_config["mem0"]["llm"]
@router.get("/mem0/embedder", response_model=EmbedderProvider)
async def get_embedder_configuration(db: Session = Depends(get_db)):
"""Get only the Embedder configuration."""
config = get_config_from_db(db)
embedder_config = config.get("mem0", {}).get("embedder", {})
return embedder_config
@router.put("/mem0/embedder", response_model=EmbedderProvider)
async def update_embedder_configuration(embedder_config: EmbedderProvider, db: Session = Depends(get_db)):
"""Update only the Embedder configuration."""
current_config = get_config_from_db(db)
# Ensure mem0 key exists
if "mem0" not in current_config:
current_config["mem0"] = {}
# Update the Embedder configuration
current_config["mem0"]["embedder"] = embedder_config.dict(exclude_none=True)
# Save the configuration to database
save_config_to_db(db, current_config)
reset_memory_client()
return current_config["mem0"]["embedder"]
@router.get("/mem0/vector_store", response_model=Optional[VectorStoreProvider])
async def get_vector_store_configuration(db: Session = Depends(get_db)):
"""Get only the Vector Store configuration."""
config = get_config_from_db(db)
vector_store_config = config.get("mem0", {}).get("vector_store", None)
return vector_store_config
@router.put("/mem0/vector_store", response_model=VectorStoreProvider)
async def update_vector_store_configuration(vector_store_config: VectorStoreProvider, db: Session = Depends(get_db)):
"""Update only the Vector Store configuration."""
current_config = get_config_from_db(db)
# Ensure mem0 key exists
if "mem0" not in current_config:
current_config["mem0"] = {}
# Update the Vector Store configuration
current_config["mem0"]["vector_store"] = vector_store_config.dict(exclude_none=True)
# Save the configuration to database
save_config_to_db(db, current_config)
reset_memory_client()
return current_config["mem0"]["vector_store"]
@router.get("/openmemory", response_model=OpenMemoryConfig)
async def get_openmemory_configuration(db: Session = Depends(get_db)):
"""Get only the OpenMemory configuration."""
config = get_config_from_db(db)
openmemory_config = config.get("openmemory", {})
return openmemory_config
@router.put("/openmemory", response_model=OpenMemoryConfig)
async def update_openmemory_configuration(openmemory_config: OpenMemoryConfig, db: Session = Depends(get_db)):
"""Update only the OpenMemory configuration."""
current_config = get_config_from_db(db)
# Ensure openmemory key exists
if "openmemory" not in current_config:
current_config["openmemory"] = {}
# Update the OpenMemory configuration
current_config["openmemory"].update(openmemory_config.dict(exclude_none=True))
# Save the configuration to database
save_config_to_db(db, current_config)
reset_memory_client()
return current_config["openmemory"]
-694
View File
@@ -1,694 +0,0 @@
import logging
from datetime import UTC, datetime
from typing import List, Optional, Set
from uuid import UUID
from app.database import get_db
from app.models import (
AccessControl,
App,
Category,
Memory,
MemoryAccessLog,
MemoryState,
MemoryStatusHistory,
User,
)
from app.schemas import MemoryResponse
from app.utils.memory import get_memory_client
from app.utils.permissions import check_memory_access_permissions
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi_pagination import Page, Params
from fastapi_pagination.ext.sqlalchemy import paginate as sqlalchemy_paginate
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
router = APIRouter(prefix="/api/v1/memories", tags=["memories"])
def get_memory_or_404(db: Session, memory_id: UUID) -> Memory:
memory = db.query(Memory).filter(Memory.id == memory_id).first()
if not memory:
raise HTTPException(status_code=404, detail="Memory not found")
return memory
def update_memory_state(db: Session, memory_id: UUID, new_state: MemoryState, user_id: UUID):
memory = get_memory_or_404(db, memory_id)
old_state = memory.state
# Update memory state
memory.state = new_state
if new_state == MemoryState.archived:
memory.archived_at = datetime.now(UTC)
elif new_state == MemoryState.deleted:
memory.deleted_at = datetime.now(UTC)
# Record state change
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user_id,
old_state=old_state,
new_state=new_state
)
db.add(history)
db.commit()
return memory
def get_accessible_memory_ids(db: Session, app_id: UUID) -> Set[UUID]:
"""
Get the set of memory IDs that the app has access to based on app-level ACL rules.
Returns all memory IDs if no specific restrictions are found.
"""
# Get app-level access controls
app_access = db.query(AccessControl).filter(
AccessControl.subject_type == "app",
AccessControl.subject_id == app_id,
AccessControl.object_type == "memory"
).all()
# If no app-level rules exist, return None to indicate all memories are accessible
if not app_access:
return None
# Initialize sets for allowed and denied memory IDs
allowed_memory_ids = set()
denied_memory_ids = set()
# Process app-level rules
for rule in app_access:
if rule.effect == "allow":
if rule.object_id: # Specific memory access
allowed_memory_ids.add(rule.object_id)
else: # All memories access
return None # All memories allowed
elif rule.effect == "deny":
if rule.object_id: # Specific memory denied
denied_memory_ids.add(rule.object_id)
else: # All memories denied
return set() # No memories accessible
# Remove denied memories from allowed set
if allowed_memory_ids:
allowed_memory_ids -= denied_memory_ids
return allowed_memory_ids
# List all memories with filtering
@router.get("/", response_model=Page[MemoryResponse])
async def list_memories(
user_id: str,
app_id: Optional[UUID] = None,
from_date: Optional[int] = Query(
None,
description="Filter memories created after this date (timestamp)",
examples=[1718505600]
),
to_date: Optional[int] = Query(
None,
description="Filter memories created before this date (timestamp)",
examples=[1718505600]
),
categories: Optional[str] = None,
params: Params = Depends(),
search_query: Optional[str] = None,
sort_column: Optional[str] = Query(None, description="Column to sort by (memory, categories, app_name, created_at)"),
sort_direction: Optional[str] = Query(None, description="Sort direction (asc or desc)"),
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived,
Memory.content.ilike(f"%{search_query}%") if search_query else True
)
# Apply filters
if app_id:
query = query.filter(Memory.app_id == app_id)
if from_date:
from_datetime = datetime.fromtimestamp(from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if to_date:
to_datetime = datetime.fromtimestamp(to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Add joins for app and categories after filtering
query = query.outerjoin(App, Memory.app_id == App.id)
query = query.outerjoin(Memory.categories)
# Apply category filter if provided
if categories:
category_list = [c.strip() for c in categories.split(",")]
query = query.filter(Category.name.in_(category_list))
# Apply sorting if specified
if sort_column:
sort_field = getattr(Memory, sort_column, None)
if sort_field:
query = query.order_by(sort_field.desc()) if sort_direction == "desc" else query.order_by(sort_field.asc())
# Add eager loading for app and categories
query = query.options(
joinedload(Memory.app),
joinedload(Memory.categories)
).distinct(Memory.id)
# Get paginated results with transformer
return sqlalchemy_paginate(
query,
params,
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
if check_memory_access_permissions(db, memory, app_id)
]
)
# Get all categories
@router.get("/categories")
async def get_categories(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get unique categories associated with the user's memories
# Get all memories
memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted, Memory.state != MemoryState.archived).all()
# Get all categories from memories
categories = [category for memory in memories for category in memory.categories]
# Get unique categories
unique_categories = list(set(categories))
return {
"categories": unique_categories,
"total": len(unique_categories)
}
class CreateMemoryRequest(BaseModel):
user_id: str
text: str
metadata: dict = {}
infer: bool = True
app: str = "openmemory"
# Create new memory
@router.post("/")
async def create_memory(
request: CreateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get or create app
app_obj = db.query(App).filter(App.name == request.app,
App.owner_id == user.id).first()
if not app_obj:
app_obj = App(name=request.app, owner_id=user.id)
db.add(app_obj)
db.commit()
db.refresh(app_obj)
# Check if app is active
if not app_obj.is_active:
raise HTTPException(status_code=403, detail=f"App {request.app} is currently paused on OpenMemory. Cannot create new memories.")
# Log what we're about to do
logging.info(f"Creating memory for user_id: {request.user_id} with app: {request.app}")
# Try to get memory client safely
try:
memory_client = get_memory_client()
if not memory_client:
raise Exception("Memory client is not available")
except Exception as client_error:
logging.warning(f"Memory client unavailable: {client_error}. Creating memory in database only.")
# Return a json response with the error
return {
"error": str(client_error)
}
# Try to save to Qdrant via memory_client
try:
qdrant_response = memory_client.add(
request.text,
user_id=request.user_id, # Use string user_id to match search
metadata={
"source_app": "openmemory",
"mcp_client": request.app,
},
infer=request.infer
)
# Log the response for debugging
logging.info(f"Qdrant response: {qdrant_response}")
# Process Qdrant response
if isinstance(qdrant_response, dict) and 'results' in qdrant_response:
created_memories = []
for result in qdrant_response['results']:
if result['event'] == 'ADD':
# Get the Qdrant-generated ID
memory_id = UUID(result['id'])
# Check if memory already exists
existing_memory = db.query(Memory).filter(Memory.id == memory_id).first()
if existing_memory:
# Update existing memory
existing_memory.state = MemoryState.active
existing_memory.content = result['memory']
memory = existing_memory
else:
# Create memory with the EXACT SAME ID from Qdrant
memory = Memory(
id=memory_id, # Use the same ID that Qdrant generated
user_id=user.id,
app_id=app_obj.id,
content=result['memory'],
metadata_=request.metadata,
state=MemoryState.active
)
db.add(memory)
# Create history entry
history = MemoryStatusHistory(
memory_id=memory_id,
changed_by=user.id,
old_state=MemoryState.deleted if existing_memory else MemoryState.deleted,
new_state=MemoryState.active
)
db.add(history)
created_memories.append(memory)
# Commit all changes at once
if created_memories:
db.commit()
for memory in created_memories:
db.refresh(memory)
# Return the first memory (for API compatibility)
# but all memories are now saved to the database
return created_memories[0]
except Exception as qdrant_error:
logging.warning(f"Qdrant operation failed: {qdrant_error}.")
# Return a json response with the error
return {
"error": str(qdrant_error)
}
# Get memory by ID
@router.get("/{memory_id}")
async def get_memory(
memory_id: UUID,
db: Session = Depends(get_db)
):
memory = get_memory_or_404(db, memory_id)
return {
"id": memory.id,
"text": memory.content,
"created_at": int(memory.created_at.timestamp()),
"state": memory.state.value,
"app_id": memory.app_id,
"app_name": memory.app.name if memory.app else None,
"categories": [category.name for category in memory.categories],
"metadata_": memory.metadata_
}
class DeleteMemoriesRequest(BaseModel):
memory_ids: List[UUID]
user_id: str
# Delete multiple memories
@router.delete("/")
async def delete_memories(
request: DeleteMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get memory client to delete from vector store
try:
memory_client = get_memory_client()
if not memory_client:
raise HTTPException(
status_code=503,
detail="Memory client is not available"
)
except HTTPException:
raise
except Exception as client_error:
logging.error(f"Memory client initialization failed: {client_error}")
raise HTTPException(
status_code=503,
detail=f"Memory service unavailable: {str(client_error)}"
)
# Delete from vector store then mark as deleted in database
for memory_id in request.memory_ids:
try:
memory_client.delete(str(memory_id))
except Exception as delete_error:
logging.warning(f"Failed to delete memory {memory_id} from vector store: {delete_error}")
update_memory_state(db, memory_id, MemoryState.deleted, user.id)
return {"message": f"Successfully deleted {len(request.memory_ids)} memories"}
# Archive memories
@router.post("/actions/archive")
async def archive_memories(
memory_ids: List[UUID],
user_id: UUID,
db: Session = Depends(get_db)
):
for memory_id in memory_ids:
update_memory_state(db, memory_id, MemoryState.archived, user_id)
return {"message": f"Successfully archived {len(memory_ids)} memories"}
class PauseMemoriesRequest(BaseModel):
memory_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
app_id: Optional[UUID] = None
all_for_app: bool = False
global_pause: bool = False
state: Optional[MemoryState] = None
user_id: str
# Pause access to memories
@router.post("/actions/pause")
async def pause_memories(
request: PauseMemoriesRequest,
db: Session = Depends(get_db)
):
global_pause = request.global_pause
all_for_app = request.all_for_app
app_id = request.app_id
memory_ids = request.memory_ids
category_ids = request.category_ids
state = request.state or MemoryState.paused
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_id = user.id
if global_pause:
# Pause all memories
memories = db.query(Memory).filter(
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": "Successfully paused all memories"}
if app_id:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.app_id == app_id,
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused all memories for app {app_id}"}
if all_for_app and memory_ids:
# Pause all memories for an app
memories = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
Memory.id.in_(memory_ids)
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": "Successfully paused all memories"}
if memory_ids:
# Pause specific memories
for memory_id in memory_ids:
update_memory_state(db, memory_id, state, user_id)
return {"message": f"Successfully paused {len(memory_ids)} memories"}
if category_ids:
# Pause memories by category
memories = db.query(Memory).join(Memory.categories).filter(
Category.id.in_(category_ids),
Memory.state != MemoryState.deleted,
Memory.state != MemoryState.archived
).all()
for memory in memories:
update_memory_state(db, memory.id, state, user_id)
return {"message": f"Successfully paused memories in {len(category_ids)} categories"}
raise HTTPException(status_code=400, detail="Invalid pause request parameters")
# Get memory access logs
@router.get("/{memory_id}/access-log")
async def get_memory_access_log(
memory_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
query = db.query(MemoryAccessLog).filter(MemoryAccessLog.memory_id == memory_id)
total = query.count()
logs = query.order_by(MemoryAccessLog.accessed_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Get app name
for log in logs:
app = db.query(App).filter(App.id == log.app_id).first()
log.app_name = app.name if app else None
return {
"total": total,
"page": page,
"page_size": page_size,
"logs": logs
}
class UpdateMemoryRequest(BaseModel):
memory_content: str
user_id: str
# Update a memory
@router.put("/{memory_id}")
async def update_memory(
memory_id: UUID,
request: UpdateMemoryRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
memory = get_memory_or_404(db, memory_id)
memory.content = request.memory_content
db.commit()
db.refresh(memory)
return memory
class FilterMemoriesRequest(BaseModel):
user_id: str
page: int = 1
size: int = 10
search_query: Optional[str] = None
app_ids: Optional[List[UUID]] = None
category_ids: Optional[List[UUID]] = None
sort_column: Optional[str] = None
sort_direction: Optional[str] = None
from_date: Optional[int] = None
to_date: Optional[int] = None
show_archived: Optional[bool] = False
@router.post("/filter", response_model=Page[MemoryResponse])
async def filter_memories(
request: FilterMemoriesRequest,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == request.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Build base query
query = db.query(Memory).filter(
Memory.user_id == user.id,
Memory.state != MemoryState.deleted,
)
# Filter archived memories based on show_archived parameter
if not request.show_archived:
query = query.filter(Memory.state != MemoryState.archived)
# Apply search filter
if request.search_query:
query = query.filter(Memory.content.ilike(f"%{request.search_query}%"))
# Apply app filter
if request.app_ids:
query = query.filter(Memory.app_id.in_(request.app_ids))
# Add joins for app and categories
query = query.outerjoin(App, Memory.app_id == App.id)
# Apply category filter
if request.category_ids:
query = query.join(Memory.categories).filter(Category.id.in_(request.category_ids))
else:
query = query.outerjoin(Memory.categories)
# Apply date filters
if request.from_date:
from_datetime = datetime.fromtimestamp(request.from_date, tz=UTC)
query = query.filter(Memory.created_at >= from_datetime)
if request.to_date:
to_datetime = datetime.fromtimestamp(request.to_date, tz=UTC)
query = query.filter(Memory.created_at <= to_datetime)
# Apply sorting
if request.sort_column and request.sort_direction:
sort_direction = request.sort_direction.lower()
if sort_direction not in ['asc', 'desc']:
raise HTTPException(status_code=400, detail="Invalid sort direction")
sort_mapping = {
'memory': Memory.content,
'app_name': App.name,
'created_at': Memory.created_at
}
if request.sort_column not in sort_mapping:
raise HTTPException(status_code=400, detail="Invalid sort column")
sort_field = sort_mapping[request.sort_column]
if sort_direction == 'desc':
query = query.order_by(sort_field.desc())
else:
query = query.order_by(sort_field.asc())
else:
# Default sorting
query = query.order_by(Memory.created_at.desc())
# Add eager loading for categories and make the query distinct
query = query.options(
joinedload(Memory.categories)
).distinct(Memory.id)
# Use fastapi-pagination's paginate function
return sqlalchemy_paginate(
query,
Params(page=request.page, size=request.size),
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)
@router.get("/{memory_id}/related", response_model=Page[MemoryResponse])
async def get_related_memories(
memory_id: UUID,
user_id: str,
params: Params = Depends(),
db: Session = Depends(get_db)
):
# Validate user
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get the source memory
memory = get_memory_or_404(db, memory_id)
# Extract category IDs from the source memory
category_ids = [category.id for category in memory.categories]
if not category_ids:
return Page.create([], total=0, params=params)
# Build query for related memories
query = db.query(Memory).distinct(Memory.id).filter(
Memory.user_id == user.id,
Memory.id != memory_id,
Memory.state != MemoryState.deleted
).join(Memory.categories).filter(
Category.id.in_(category_ids)
).options(
joinedload(Memory.categories),
joinedload(Memory.app)
).order_by(
func.count(Category.id).desc(),
Memory.created_at.desc()
).group_by(Memory.id)
# ⚡ Force page size to be 5
params = Params(page=params.page, size=5)
return sqlalchemy_paginate(
query,
params,
transformer=lambda items: [
MemoryResponse(
id=memory.id,
content=memory.content,
created_at=memory.created_at,
state=memory.state.value,
app_id=memory.app_id,
app_name=memory.app.name if memory.app else None,
categories=[category.name for category in memory.categories],
metadata_=memory.metadata_
)
for memory in items
]
)
-29
View File
@@ -1,29 +0,0 @@
from app.database import get_db
from app.models import App, Memory, MemoryState, User
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
@router.get("/")
async def get_profile(
user_id: str,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get total number of memories
total_memories = db.query(Memory).filter(Memory.user_id == user.id, Memory.state != MemoryState.deleted).count()
# Get total number of apps
apps = db.query(App).filter(App.owner == user)
total_apps = apps.count()
return {
"total_memories": total_memories,
"total_apps": total_apps,
"apps": apps.all()
}
-65
View File
@@ -1,65 +0,0 @@
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, validator
class MemoryBase(BaseModel):
content: str
metadata_: Optional[dict] = Field(default_factory=dict)
class MemoryCreate(MemoryBase):
user_id: UUID
app_id: UUID
class Category(BaseModel):
name: str
class App(BaseModel):
id: UUID
name: str
class Memory(MemoryBase):
id: UUID
user_id: UUID
app_id: UUID
created_at: datetime
updated_at: Optional[datetime] = None
state: str
categories: Optional[List[Category]] = None
app: App
model_config = ConfigDict(from_attributes=True)
class MemoryUpdate(BaseModel):
content: Optional[str] = None
metadata_: Optional[dict] = None
state: Optional[str] = None
class MemoryResponse(BaseModel):
id: UUID
content: str
created_at: int
state: str
app_id: UUID
app_name: str
categories: List[str]
metadata_: Optional[dict] = None
@validator('created_at', pre=True)
def convert_to_epoch(cls, v):
if isinstance(v, datetime):
return int(v.timestamp())
return v
class PaginatedMemoryResponse(BaseModel):
items: List[MemoryResponse]
total: int
page: int
size: int
pages: int
@@ -1,43 +0,0 @@
import logging
from typing import List
from app.utils.prompts import MEMORY_CATEGORIZATION_PROMPT
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel
from tenacity import retry, stop_after_attempt, wait_exponential
load_dotenv()
openai_client = OpenAI()
class MemoryCategories(BaseModel):
categories: List[str]
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
def get_categories_for_memory(memory: str) -> List[str]:
try:
messages = [
{"role": "system", "content": MEMORY_CATEGORIZATION_PROMPT},
{"role": "user", "content": memory}
]
# Let OpenAI handle the pydantic parsing directly
completion = openai_client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=MemoryCategories,
temperature=0
)
parsed: MemoryCategories = completion.choices[0].message.parsed
return [cat.strip().lower() for cat in parsed.categories]
except Exception as e:
logging.error(f"[ERROR] Failed to get categories: {e}")
try:
logging.debug(f"[DEBUG] Raw response: {completion.choices[0].message.content}")
except Exception as debug_e:
logging.debug(f"[DEBUG] Could not extract raw response: {debug_e}")
raise
-33
View File
@@ -1,33 +0,0 @@
from typing import Tuple
from app.models import App, User
from sqlalchemy.orm import Session
def get_or_create_user(db: Session, user_id: str) -> User:
"""Get or create a user with the given user_id"""
user = db.query(User).filter(User.user_id == user_id).first()
if not user:
user = User(user_id=user_id)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_or_create_app(db: Session, user: User, app_id: str) -> App:
"""Get or create an app for the given user"""
app = db.query(App).filter(App.owner_id == user.id, App.name == app_id).first()
if not app:
app = App(owner_id=user.id, name=app_id)
db.add(app)
db.commit()
db.refresh(app)
return app
def get_user_and_app(db: Session, user_id: str, app_id: str) -> Tuple[User, App]:
"""Get or create both user and their app"""
user = get_or_create_user(db, user_id)
app = get_or_create_app(db, user, app_id)
return user, app

Some files were not shown because too many files have changed in this diff Show More