Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e00815589 | ||
|
|
e2fd8dc7a8 | ||
|
|
d19cde4a15 | ||
|
|
565a76960b | ||
|
|
d987243584 | ||
|
|
4ca67801c0 | ||
|
|
739402946c | ||
|
|
240168aefe | ||
|
|
07b9ad519d | ||
|
|
6ba334ef94 | ||
|
|
8bd7b913c6 | ||
|
|
367512b525 | ||
|
|
79e04c56f6 | ||
|
|
7b109d90de | ||
|
|
f687951b1a | ||
|
|
70bc9e51d5 | ||
|
|
0107fd53b8 | ||
|
|
54a03cc721 | ||
|
|
e95de4ca50 | ||
|
|
a623cfaf76 | ||
|
|
92491c00c2 |
@@ -12,7 +12,7 @@
|
||||
"name": "mem0",
|
||||
"source": "./mem0-plugin",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||
"version": "0.1.1"
|
||||
"version": "0.1.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
| Benchmark | Old | New | Tokens | Latency p50 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **LoCoMo** | 71.4 | **91.6** | 7.0K | 0.88s |
|
||||
| **LongMemEval** | 67.8 | **93.4** | 6.8K | 1.09s |
|
||||
| **LongMemEval** | 67.8 | **94.8** | 6.8K | 1.09s |
|
||||
| **BEAM (1M)** | — | **64.1** | 6.7K | 1.00s |
|
||||
| **BEAM (10M)** | — | **48.6** | 6.9K | 1.05s |
|
||||
|
||||
@@ -58,12 +58,13 @@ All benchmarks run on the same production-representative model stack. Single-pas
|
||||
- **Agent-generated facts are first-class** -- when an agent confirms an action, that information is now stored with equal weight.
|
||||
- **Entity linking** -- entities are extracted, embedded, and linked across memories for retrieval boosting.
|
||||
- **Multi-signal retrieval** -- semantic, BM25 keyword, and entity matching scored in parallel and fused.
|
||||
- **Temporal Reasoning** -- time-aware retrieval that ranks the right dated instance for queries about current state, past events, and upcoming plans.
|
||||
|
||||
See the [migration guide](https://docs.mem0.ai/migration/oss-v2-to-v3) for upgrade instructions. The [evaluation framework](https://github.com/mem0ai/memory-benchmarks) is open-sourced so anyone can reproduce the numbers.
|
||||
|
||||
## Research Highlights
|
||||
- **91.6 on LoCoMo** -- +20 points over the previous algorithm
|
||||
- **93.4 on LongMemEval** -- +26 points, with +53.6 on assistant memory recall
|
||||
- **94.8 on LongMemEval** -- +27 points, with +53.6 on assistant memory recall
|
||||
- **64.1 on BEAM (1M)** -- production-scale memory evaluation at 1M tokens
|
||||
- [Read the full paper](https://mem0.ai/research)
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ openapi: get /v1/event/{event_id}/
|
||||
---
|
||||
|
||||
Retrieve details about a specific event by passing its `event_id`. This endpoint is particularly helpful for tracking the status, payload, and completion details of asynchronous memory operations.
|
||||
|
||||
For `POST /v3/memories/add/`, the event confirms that the write pipeline completed. Temporal reasoning enrichment runs asynchronously by default, so the event may be `SUCCEEDED` slightly before temporal ranking signals are available to subsequent `search` calls.
|
||||
|
||||
@@ -83,4 +83,3 @@ The request is queued for background processing. The response contains an `event
|
||||
<Info>
|
||||
Poll the event status via `GET /v1/event/{event_id}/`. Status will be `SUCCEEDED` or `FAILED` once processing completes.
|
||||
</Info>
|
||||
|
||||
|
||||
@@ -64,4 +64,3 @@ memories = client.get_all(
|
||||
<Info>
|
||||
The response is a paginated envelope with `count`, `next`, `previous`, and `results`. Use `page` and `page_size` query params to step through results.
|
||||
</Info>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ related_memories = client.search(
|
||||
{
|
||||
"id": "ea925981-272f-40dd-b576-be64e4871429",
|
||||
"memory": "Likes to play cricket and plays cricket on weekends.",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"category": "hobbies"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,21 @@ description: "Major product launches, headline features, and milestones for Mem0
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-13" description="Temporal Reasoning for Mem0 Platform v3">
|
||||
|
||||
**Temporal Reasoning — Time-Aware Retrieval for Platform v3**
|
||||
|
||||
Mem0 Platform v3 can now interpret time-aware memories and queries so assistants retrieve the right information for questions about the past, upcoming plans, and current state.
|
||||
|
||||
- **Time-aware search intent** — Queries like `last week`, `upcoming`, `right now`, and `as of March 2025` return contextually appropriate results automatically
|
||||
- **Enabled by default** — No per-request toggle required for v3 writes or searches
|
||||
- **Anchored relative queries** — `reference_date` anchors relative search phrases for tests, backfills, and reproducible demos
|
||||
- **Normal response shape** — Temporal reasoning affects ranking while preserving existing client response patterns
|
||||
|
||||
See [Temporal Reasoning](/platform/features/temporal-reasoning) for usage details.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-08" description="Memory Decay">
|
||||
|
||||
**Memory Decay — Recently-Used Memories Surface Higher, Automatically**
|
||||
@@ -112,4 +127,4 @@ Major expansion of the provider ecosystem:
|
||||
|
||||
First skill launch — a dedicated Mem0 skill providing platform API reference, quickstart patterns, and integration examples directly inside agent sessions. Available on [skills.sh](https://skills.sh) for any compatible AI coding agent.
|
||||
|
||||
</Update>
|
||||
</Update>
|
||||
|
||||
@@ -4,6 +4,17 @@ description: "Release notes for the Mem0 hosted platform — backend, dashboard,
|
||||
mode: "wide"
|
||||
---
|
||||
|
||||
<Update label="2026-05-13" description="">
|
||||
|
||||
**New Features:**
|
||||
- **Memory:** Added Temporal Reasoning for Platform v3 to improve ranking for time-aware queries such as `last week`, `upcoming`, `right now`, and `as of ...`
|
||||
- **Search:** Added `reference_date` support to anchor relative temporal queries for tests, backfills, and reproducible demos
|
||||
|
||||
**Improvements:**
|
||||
- **API:** Temporal reasoning preserves the normal client response shape for search and get-all results
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="2026-05-04" description="">
|
||||
|
||||
**New Features:**
|
||||
@@ -301,4 +312,3 @@ mode: "wide"
|
||||
- **Core:** Fixed unicode error in user_id, agent_id, run_id and app_id
|
||||
|
||||
</Update>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ mode: "wide"
|
||||
**Breaking Changes:**
|
||||
- **`add()` returns ADD-only events** — No more `"UPDATE"` or `"DELETE"` events. Memories accumulate; nothing is overwritten ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`search()` default `threshold` is now `0.1`** — Pass `threshold=0.0` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, and entity boost into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries. Per-signal scores are not exposed on the response ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
|
||||
- **`search()` `score` is now a combined multi-signal score** — The top-level `score` fuses semantic similarity, BM25 keyword match, entity signals, and temporal boosts into one value. Absolute numbers shift versus the old raw cosine score; retune any hard thresholds against representative queries ([#4805](https://github.com/mem0ai/mem0/pull/4805), [#4836](https://github.com/mem0ai/mem0/pull/4836))
|
||||
- **`search()` default `rerank` is now `False`** — Pass `rerank=True` for previous behavior ([#4805](https://github.com/mem0ai/mem0/pull/4805))
|
||||
- **`top_k` default changed 100 → 20** in `Memory.get_all()` and `Memory.search()` (sync + async). Pass `top_k=100` explicitly to restore the old behavior ([#4843](https://github.com/mem0ai/mem0/pull/4843))
|
||||
- **Entity ID validation:** `user_id` / `agent_id` / `run_id` are trimmed; empty-string and whitespace-only values now raise `ValueError` ([#4843](https://github.com/mem0ai/mem0/pull/4843))
|
||||
|
||||
@@ -156,6 +156,10 @@ const memories = memory.search("food preferences", {
|
||||
Expect an array of memory documents. Platform responses include vectors, metadata, and timestamps; OSS returns your stored schema.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
On Mem0 Platform v3, time-aware queries use Temporal Reasoning internally while preserving the normal search response shape. See <Link href="/platform/features/temporal-reasoning">Temporal Reasoning</Link>.
|
||||
</Note>
|
||||
|
||||
## Filter patterns
|
||||
|
||||
Filters help narrow down search results. Common use cases:
|
||||
@@ -251,4 +255,4 @@ For the full list of filter logic, comparison operators, and optional search par
|
||||
icon="rocket"
|
||||
href="/cookbooks/operations/support-inbox"
|
||||
/>
|
||||
</CardGroup>
|
||||
</CardGroup>
|
||||
|
||||
+3
-2
@@ -72,7 +72,8 @@
|
||||
"platform/features/entity-scoped-memory",
|
||||
"platform/features/async-client",
|
||||
"platform/features/multimodal-support",
|
||||
"platform/features/custom-categories"
|
||||
"platform/features/custom-categories",
|
||||
"platform/features/temporal-reasoning"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1144,4 +1145,4 @@
|
||||
"destination": "/introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or Platform `output_f
|
||||
### Features - Advanced Retrieval
|
||||
- [Advanced Retrieval](https://docs.mem0.ai/platform/features/advanced-retrieval) [Platform]: Use when the user needs keyword search, reranking, or hybrid retrieval.
|
||||
- [Criteria-Based Retrieval](https://docs.mem0.ai/platform/features/criteria-retrieval) [Platform]: Use when targeting memories by custom criteria, not just semantic similarity.
|
||||
- [Temporal Reasoning](https://docs.mem0.ai/platform/features/temporal-reasoning) [Platform]: Use when time-aware searches like last week, upcoming, or right now need better result ordering.
|
||||
- [Contextual Add](https://docs.mem0.ai/platform/features/contextual-add) [Platform]: Use when `add()` should consider the surrounding conversation, not just the latest turn.
|
||||
- [Custom Instructions](https://docs.mem0.ai/platform/features/custom-instructions) [Platform]: Use when tailoring what Mem0 extracts and stores on Platform.
|
||||
- [Memory Decay](https://docs.mem0.ai/platform/features/memory-decay) [Platform]: Use when search results should boost recently-reinforced memories and dampen stale ones — opt-in per project, search-time only, never filters candidates out.
|
||||
|
||||
@@ -42,7 +42,7 @@ Previously, when an agent said something like "I've booked your flight for March
|
||||
|
||||
### Retrieval is hybrid now
|
||||
|
||||
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, or entities that appear across multiple memories. The response shape is unchanged:
|
||||
Search now uses hybrid retrieval, which improves ranking quality — especially for queries involving exact keywords, proper nouns, entities that appear across multiple memories, and time-aware queries (via Temporal Reasoning). The response shape is unchanged:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -58,7 +58,7 @@ Search now uses hybrid retrieval, which improves ranking quality — especially
|
||||
}
|
||||
```
|
||||
|
||||
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries.
|
||||
The top-level `score` remains a `[0, 1]` value. Relative ranking between results stays comparable to v2, but absolute numbers shift since the scoring method changed — retune any hard thresholds in your app against representative queries. Temporal signals are applied internally during ranking and are not returned as extra client-facing fields.
|
||||
|
||||
## API Changes
|
||||
|
||||
@@ -289,6 +289,7 @@ If your application previously read graph relations from the API response (`rela
|
||||
- **V1 and V2 endpoints continue to work.** There is no requirement to migrate to V3 endpoints immediately.
|
||||
- **Existing memories are preserved.** The new algorithm does not modify or re-process previously stored memories.
|
||||
- **Search response shape is unchanged.** The top-level `score` and `results[]` array are the same; existing code that reads `score` continues to work. What changed is the scoring method behind the number (multi-signal fusion instead of pure cosine), so the absolute values shift even when ranking stays comparable.
|
||||
- **Search remains backward-compatible at the top level.** Existing code that reads `results[]` and `score` continues to work. Temporal signals are applied internally during retrieval and do not change the client response shape.
|
||||
- **List response shape changed.** `get_all` now returns a paginated envelope (`{count, next, previous, results}`) instead of a bare `{results: [...]}`. Update code that reads `response["results"]` to continue working, or switch to the client SDKs which handle both shapes.
|
||||
|
||||
## Performance Improvements
|
||||
|
||||
@@ -131,7 +131,7 @@ Auth is on by default. Protected endpoints require either a JWT (from the dashbo
|
||||
| Mode | How to send it | When to use it |
|
||||
|---|---|---|
|
||||
| Bearer JWT | `Authorization: Bearer <access_token>` | Dashboard sessions; tokens come from `POST /auth/login` and refresh via `POST /auth/refresh` |
|
||||
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access scoped to a single dashboard user |
|
||||
| Per-user API key | `X-API-Key: m0sk_...` | Programmatic access tied to the single admin user today. Per-user scoping arrives with the upcoming multi-user invite flow |
|
||||
| Legacy `ADMIN_API_KEY` | `X-API-Key: <env value>` | Back-compat for deployments that set the `ADMIN_API_KEY` env var |
|
||||
| `AUTH_DISABLED=true` | — | Local development only; bypasses auth entirely |
|
||||
|
||||
@@ -182,7 +182,7 @@ curl -X POST http://localhost:8888/memories \
|
||||
}'
|
||||
```
|
||||
|
||||
Per-user keys inherit the creating user's scope. List or revoke them via `GET /api-keys` and `DELETE /api-keys/{id}`.
|
||||
List or revoke per-user keys via `GET /api-keys` and `DELETE /api-keys/{id}`. The dashboard user issued the key remains its owner; admin-only routes such as `/configure`, `/reset`, `/entities`, and `/requests` require the issuing user to have the admin role.
|
||||
|
||||
### Legacy `ADMIN_API_KEY`
|
||||
|
||||
@@ -246,8 +246,8 @@ The OSS REST server exposes the following endpoints. None use the `/v1/` prefix.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/configure` | Set memory configuration. Rejects unbundled providers with a 400 |
|
||||
| `GET` | `/configure` | Get the current memory configuration |
|
||||
| `POST` | `/configure` | **Admin only.** Set memory configuration. Rejects unbundled providers with a 400 |
|
||||
| `GET` | `/configure` | **Admin only.** Get the current memory configuration |
|
||||
| `GET` | `/configure/providers` | List the LLM and embedder providers bundled in the container |
|
||||
| `POST` | `/memories` | Create memories |
|
||||
| `GET` | `/memories` | Get all memories (filter by `user_id`, `agent_id`, or `run_id`) |
|
||||
@@ -257,14 +257,14 @@ The OSS REST server exposes the following endpoints. None use the `/v1/` prefix.
|
||||
| `DELETE` | `/memories` | Delete all memories for an identifier |
|
||||
| `GET` | `/memories/{memory_id}/history` | Get memory history |
|
||||
| `POST` | `/search` | Search memories |
|
||||
| `POST` | `/reset` | Reset all memories |
|
||||
| `POST` | `/reset` | **Admin only.** Reset all memories |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/auth/setup-status` | Returns `{needsSetup: bool}`. Open, no auth required |
|
||||
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts are provisioned by the existing admin. |
|
||||
| `POST` | `/auth/register` | Register the first admin. Registration closes after the first admin is created; additional accounts will be provisioned by the existing admin (multi-user invite flow coming soon). |
|
||||
| `POST` | `/auth/login` | Exchange email and password for access and refresh JWTs |
|
||||
| `POST` | `/auth/refresh` | Exchange a refresh token for a new access token |
|
||||
| `GET` | `/auth/me` | Get the current authenticated user (JWT required) |
|
||||
@@ -285,14 +285,14 @@ All `/api-keys` endpoints require a JWT.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/requests?limit=N` | Recent API call log (JWT or admin key) |
|
||||
| `GET` | `/requests?limit=N` | **Admin only.** Recent API call log |
|
||||
|
||||
### Entities
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/entities` | Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
|
||||
| `DELETE` | `/entities/{entity_type}/{entity_id}` | Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
|
||||
| `GET` | `/entities` | **Admin only.** Distinct `user_id` / `agent_id` / `run_id` values with memory counts |
|
||||
| `DELETE` | `/entities/{entity_type}/{entity_id}` | **Admin only.** Cascade-delete all memories for an entity; `entity_type` is `user`, `agent`, or `run` |
|
||||
|
||||
The `/auth/*`, `/api-keys`, `/requests`, and `/entities` routes are new to the self-hosted server and primarily back the dashboard, but you can call them directly from your own tooling.
|
||||
|
||||
|
||||
+17
-2
@@ -419,7 +419,7 @@
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"description": "Array of results produced by the event."
|
||||
"description": "Array of results produced by the event. For add events, this confirms the write completed; temporal reasoning enrichment runs asynchronously by default."
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string",
|
||||
@@ -2071,7 +2071,7 @@
|
||||
"memories"
|
||||
],
|
||||
"summary": "Search memories (V3)",
|
||||
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval — the returned `score` is a combined `[0, 1]` value; per-signal component scores are not exposed on the response. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
|
||||
"description": "Relevance-ranked search across stored memories. V3 uses hybrid retrieval and can also apply temporal reasoning for time-aware queries. Entity IDs **must** be passed inside the `filters` object — top-level `user_id` / `agent_id` / `run_id` are rejected with 400. At least one entity ID is required.",
|
||||
"operationId": "memories_search_v3",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -2112,6 +2112,21 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Apply the managed reranker for better ordering (adds latency)."
|
||||
},
|
||||
"reference_date": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"nullable": true,
|
||||
"description": "Optional query anchor time for relative temporal interpretation. Accepts Unix epoch, YYYY-MM-DD, or ISO datetime."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,6 +59,14 @@ The toggle lives on the project. You enable decay by patching the project's `dec
|
||||
The toggle is exposed on the standard project-update endpoint, the same place where `multilingual` and `custom_categories` live.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=True)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: true });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
@@ -66,34 +74,6 @@ curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PR
|
||||
-d '{"decay": true}'
|
||||
```
|
||||
|
||||
```python Python
|
||||
import os
|
||||
import requests
|
||||
|
||||
org_id = os.environ["MEM0_ORG_ID"]
|
||||
project_id = os.environ["MEM0_PROJECT_ID"]
|
||||
|
||||
requests.patch(
|
||||
f"https://api.mem0.ai/api/v1/orgs/organizations/{org_id}/projects/{project_id}/",
|
||||
headers={"Authorization": f"Token {os.environ['MEM0_API_KEY']}"},
|
||||
json={"decay": True},
|
||||
)
|
||||
```
|
||||
|
||||
```javascript Node.js
|
||||
const res = await fetch(
|
||||
`https://api.mem0.ai/api/v1/orgs/organizations/${process.env.MEM0_ORG_ID}/projects/${process.env.MEM0_PROJECT_ID}/`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Token ${process.env.MEM0_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ decay: true }),
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
```json Response
|
||||
{ "message": "Updated decay" }
|
||||
```
|
||||
@@ -104,6 +84,16 @@ const res = await fetch(
|
||||
`decay` is returned on every project read. To fetch only this field, use `?fields=decay`.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
response = client.project.get(fields=["decay"])
|
||||
print(response["decay"])
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
const response = await client.project.get({ fields: ["decay"] });
|
||||
console.log(response.decay);
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/?fields=decay" \
|
||||
-H "Authorization: Token $MEM0_API_KEY"
|
||||
@@ -119,6 +109,14 @@ curl "https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID
|
||||
The toggle is fully reversible. Setting it to `false` immediately restores the pre-decay ranking; nothing about your stored memories is modified or lost.
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
client.project.update(decay=False)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
await client.project.update({ decay: false });
|
||||
```
|
||||
|
||||
```bash cURL
|
||||
curl -X PATCH https://api.mem0.ai/api/v1/orgs/organizations/$ORG_ID/projects/$PROJECT_ID/ \
|
||||
-H "Authorization: Token $MEM0_API_KEY" \
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
title: Temporal Reasoning
|
||||
description: "Time-aware memory retrieval for Mem0 Platform v3 so queries like 'last week', 'upcoming', and 'right now' return the right memories."
|
||||
icon: "clock"
|
||||
badge: "v3"
|
||||
---
|
||||
|
||||
Some memories matter because of **when** they happened, not just because they sound similar. Temporal Reasoning lets Mem0 Platform v3 understand time-aware queries and return the most contextually appropriate results.
|
||||
|
||||
<Info>
|
||||
**Use Temporal Reasoning when…**
|
||||
- Users ask questions like "what happened last week?" or "what do I have coming up?"
|
||||
- Your app stores both past events and future plans for the same person
|
||||
- You want time-aware retrieval without building your own date-parsing layer
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Temporal Reasoning is a **Mem0 Platform v3** feature. It is not available on OSS memory stores or older Platform endpoints.
|
||||
</Warning>
|
||||
|
||||
## Configure access
|
||||
|
||||
Confirm your `MEM0_API_KEY` is set and that you are using the v3 Platform client:
|
||||
|
||||
```python
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
When a memory describes an event, a future plan, or an ongoing state, Temporal Reasoning recognizes the time context so the right results surface at search time.
|
||||
|
||||
A query like `what did I do last week?` should return a completed past event — not an upcoming appointment and not a stable fact that hasn't changed. Temporal Reasoning handles that distinction automatically.
|
||||
|
||||
### Memory types Temporal Reasoning handles
|
||||
|
||||
| Type | What it represents | Example |
|
||||
| --- | --- | --- |
|
||||
| Dated occurrence | Something that happened at a known time | "I finished the Q1 review on March 10, 2025." |
|
||||
| Future plan | A future commitment or scheduled item | "I have a dentist appointment on March 18, 2025." |
|
||||
| Ongoing state | A fact that remains true over time | "I am the product lead at Acme Corp." |
|
||||
| Relationship | A durable connection between people or entities | "Priya manages Jordan." |
|
||||
| Preference | A stable preference or habit | "I prefer morning meetings." |
|
||||
|
||||
Results come back in the normal search response shape — Temporal Reasoning affects ranking, not the response format.
|
||||
|
||||
## Configure it
|
||||
|
||||
Temporal Reasoning is enabled by default for all v3 searches and writes. There is no per-request toggle.
|
||||
|
||||
Two parameters give you precise control when you need it:
|
||||
|
||||
- `timestamp` on `add()` — anchors an imported memory to the time it actually happened, rather than the time it was added to Mem0
|
||||
- `reference_date` on `search()` — resolves relative phrases like `last week` against a fixed point in time
|
||||
|
||||
<CodeGroup>
|
||||
```python Python
|
||||
from datetime import datetime, timezone
|
||||
from mem0 import MemoryClient
|
||||
|
||||
client = MemoryClient(api_key="your-api-key")
|
||||
|
||||
# Import a historical memory anchored to when it happened
|
||||
client.add(
|
||||
[{"role": "user", "content": "I finished the Q1 review on March 10, 2025."}],
|
||||
user_id="jordan",
|
||||
timestamp=int(datetime(2025, 3, 10, tzinfo=timezone.utc).timestamp()),
|
||||
)
|
||||
|
||||
# Search with a relative query anchored to a known date
|
||||
results = client.search(
|
||||
"what did I do last week?",
|
||||
filters={"user_id": "jordan"},
|
||||
reference_date="2025-03-21T00:00:00Z",
|
||||
)
|
||||
```
|
||||
|
||||
```javascript JavaScript
|
||||
import { MemoryClient } from "mem0ai";
|
||||
|
||||
const client = new MemoryClient({ apiKey: "your-api-key" });
|
||||
|
||||
// Import a historical memory anchored to when it happened
|
||||
await client.add(
|
||||
[{ role: "user", content: "I finished the Q1 review on March 10, 2025." }],
|
||||
{
|
||||
userId: "jordan",
|
||||
timestamp: Math.floor(new Date("2025-03-10T00:00:00Z").getTime() / 1000),
|
||||
}
|
||||
);
|
||||
|
||||
// Search with a relative query anchored to a known date
|
||||
const results = await client.search("what did I do last week?", {
|
||||
filters: { user_id: "jordan" },
|
||||
referenceDate: "2025-03-21T00:00:00Z",
|
||||
});
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
<Tip>
|
||||
`reference_date` is especially useful in automated tests and demos because it makes relative phrases like `last week` resolve consistently every time.
|
||||
</Tip>
|
||||
|
||||
## Supported query patterns
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Historical questions">
|
||||
Examples: `last week`, `last month`, `in March 2025`, `on 2025-03-10`
|
||||
</Accordion>
|
||||
<Accordion title="Upcoming questions">
|
||||
Examples: `upcoming`, `next week`, `tomorrow`, `what do I have coming up?`
|
||||
</Accordion>
|
||||
<Accordion title="Current-state questions">
|
||||
Examples: `right now`, `currently`, `where do I work now?`
|
||||
</Accordion>
|
||||
<Accordion title="As-of questions">
|
||||
Examples: `as of March 2025`, `where was I living as of 2024?`
|
||||
</Accordion>
|
||||
<Accordion title="Duration questions">
|
||||
Examples: `how long have I lived here?`, `since when have I worked there?`
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Verify the feature is working
|
||||
|
||||
- Run a temporal search with a time-aware query (e.g., "what did I do last week?") and confirm the memory that fits the time window ranks first.
|
||||
- Use `reference_date` in test queries so relative phrases resolve consistently across runs.
|
||||
- For backfilled data, pass `timestamp` on `add()` to confirm the memory reflects the right point in time.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Use explicit dates in source conversations when events or plans matter temporally.
|
||||
- Pass `timestamp` during historical imports so the ingestion time does not become the only time anchor.
|
||||
- Scope searches with `filters` so time-aware ranking operates inside the right user boundary.
|
||||
- Use `reference_date` in automated tests and reproducible demos.
|
||||
|
||||
<CardGroup cols={1}>
|
||||
<Card title="Memory Timestamps" icon="calendar" href="/platform/features/timestamp">
|
||||
Anchor imported memories to when they actually happened.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Snippet file="get-help.mdx" />
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -157,6 +157,32 @@ After installing, confirm the MCP server is connected:
|
||||
- **Mem0 SDK Skill** — Guides the AI on how to integrate the Mem0 SDK (Python & TypeScript) into your applications.
|
||||
- **Memory Protocol Skill** — Codex-specific skill that instructs the agent to retrieve relevant memories at task start, store learnings on completion, and capture session state before context loss. Complements the lifecycle hooks on Codex.
|
||||
|
||||
## Updating the plugin
|
||||
|
||||
When the plugin updates (new version pulled from the marketplace, or a fresh local install), the MCP server connection in your existing Claude Code / Cursor / Codex session is left holding a stale handle and stops responding. **Restart your client to reconnect:**
|
||||
|
||||
- **Claude Code:** run `/restart` in the prompt, or close and reopen the CLI.
|
||||
- **Cursor:** quit and relaunch.
|
||||
- **Codex:** restart the editor session.
|
||||
|
||||
Your `MEM0_API_KEY` doesn't need to be re-entered — the auth header is re-read from your environment on the new session. The plugin's MCP config uses `${MEM0_API_KEY}` interpolation at session start, not at install time, so as long as the env var is set persistently (in your shell profile or `~/.claude/settings.json` `env` block), reconnection is automatic on restart.
|
||||
|
||||
If reconnection still fails after a restart, check that `MEM0_API_KEY` is reachable in the new shell (`echo $MEM0_API_KEY`) and confirm you're using a key that starts with `m0-` (from https://app.mem0.ai/dashboard/api-keys, not a legacy token).
|
||||
|
||||
## Optional: tune categories for coding workflows
|
||||
|
||||
mem0 auto-tags every memory with one or more `categories` from a project-level list. The default list is consumer-oriented (`food`, `hobbies`, `music` …) — useful for chat assistants, less so for code. A one-shot script in this plugin replaces it with a coding-focused taxonomy:
|
||||
|
||||
```bash
|
||||
# Dry-run first -- prints current vs proposed, no changes:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py
|
||||
|
||||
# Actually write:
|
||||
python mem0-plugin/scripts/setup_coding_categories.py --apply
|
||||
```
|
||||
|
||||
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Once installed, the following tools are available:
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
||||
},
|
||||
{
|
||||
"command": "python3 ${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
||||
"timeout": 30
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.sh",
|
||||
"statusMessage": "Preparing pre-compaction summary..."
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/on_pre_compact.py",
|
||||
"statusMessage": "Saving session state to mem0...",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Resolve mem0 user_id with deterministic priority.
|
||||
|
||||
Resolution priority:
|
||||
1. MEM0_USER_ID env var (explicit override)
|
||||
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
4. Fallback: $USER, else "default"
|
||||
|
||||
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
the "47 user buckets per account" symptom from running on multiple
|
||||
laptops with different $USER values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||
|
||||
|
||||
def resolve_user_id() -> str:
|
||||
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||
if api_key:
|
||||
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||
fingerprint = digest[:8]
|
||||
|
||||
try:
|
||||
with open(_CACHE_PATH, "r") as f:
|
||||
cached = json.load(f)
|
||||
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||
return cached["user_id"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
derived = "mem0-" + digest[:12]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||
with open(_CACHE_PATH, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"user_id": derived,
|
||||
"source": "api_key",
|
||||
"api_key_fingerprint": fingerprint,
|
||||
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
},
|
||||
f,
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return derived
|
||||
|
||||
return os.environ.get("USER") or "default"
|
||||
@@ -0,0 +1,57 @@
|
||||
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||
#
|
||||
# Resolution priority:
|
||||
# 1. MEM0_USER_ID env var (explicit override)
|
||||
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||
# 4. Fallback: $USER, else "default"
|
||||
#
|
||||
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||
# the "47 user buckets per account" symptom from running on multiple
|
||||
# laptops with different $USER values.
|
||||
|
||||
_mem0_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum | cut -d' ' -f1
|
||||
else
|
||||
shasum -a 256 | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
_mem0_resolve_identity() {
|
||||
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_USER_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
local api_key="${MEM0_API_KEY:-}"
|
||||
local cache="$HOME/.mem0/identity.json"
|
||||
|
||||
if [ -n "$api_key" ]; then
|
||||
local digest
|
||||
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||
local fp="${digest:0:8}"
|
||||
|
||||
if [ -f "$cache" ]; then
|
||||
local cached_fp cached_id
|
||||
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||
printf '%s' "$cached_id"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local derived="mem0-${digest:0:12}"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||
printf '%s' "$derived"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s' "${USER:-default}"
|
||||
}
|
||||
|
||||
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||
export MEM0_RESOLVED_USER_ID
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||
@@ -22,7 +26,7 @@ if [ -z "$FILE_PATH" ]; then
|
||||
fi
|
||||
|
||||
case "$FILE_PATH" in
|
||||
*/MEMORY.md|*/memory/*.md|*/.claude/*/memory/*)
|
||||
*/MEMORY.md|*/.claude/memory/*)
|
||||
echo "BLOCKED: Do not write to $FILE_PATH. Use the mem0 MCP \`add_memory\` tool instead to persist memories. This project uses mem0 for all memory storage." >&2
|
||||
exit 2
|
||||
;;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the post-compaction summary into mem0.
|
||||
|
||||
PreCompact hooks fire BEFORE the summary is generated, so they can't
|
||||
store the actual compact-summary text. This script runs at
|
||||
SessionStart with source=compact, reads the transcript, finds the
|
||||
most recent entry flagged isCompactSummary=true, and stores it as a
|
||||
memory tagged metadata.type=compact_summary.
|
||||
|
||||
Input: JSON on stdin with transcript_path, session_id, source
|
||||
Output: stderr logs only (exit 0 always -- must not block)
|
||||
|
||||
Spawned in the background by on_session_start.sh; the user-facing
|
||||
bootstrap text continues without waiting on the network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-compact-summary] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 2000
|
||||
MAX_SUMMARY_CHARS = 50000
|
||||
# Compact summaries describe a single session's state -- stale after a quarter.
|
||||
COMPACT_SUMMARY_EXPIRY_DAYS = 90
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
try:
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
if file_size == 0:
|
||||
return []
|
||||
chunk_size = min(file_size, n * 4096)
|
||||
f.seek(max(0, file_size - chunk_size))
|
||||
data = f.read().decode("utf-8", errors="replace")
|
||||
return data.splitlines()[-n:]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def find_compact_summary(lines: list[str]) -> str:
|
||||
"""Walk transcript backwards, return text content of the most recent
|
||||
entry flagged isCompactSummary=true. Empty string if none found."""
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not entry.get("isCompactSummary"):
|
||||
continue
|
||||
|
||||
message = entry.get("message", {})
|
||||
content = message.get("content", [])
|
||||
if isinstance(content, str):
|
||||
return content[:MAX_SUMMARY_CHARS]
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
return "\n".join(parts).strip()[:MAX_SUMMARY_CHARS]
|
||||
return ""
|
||||
|
||||
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary}],
|
||||
"user_id": user_id,
|
||||
"metadata": {
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
},
|
||||
"infer": False,
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
log.info("Compact summary stored")
|
||||
return True
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping capture")
|
||||
return
|
||||
|
||||
try:
|
||||
hook_input = json.loads(sys.stdin.read())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
log.debug("No valid JSON on stdin")
|
||||
return
|
||||
|
||||
transcript_path = hook_input.get("transcript_path", "")
|
||||
if not transcript_path:
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
log.debug("Transcript empty or unreadable: %s", transcript_path)
|
||||
return
|
||||
|
||||
summary = find_compact_summary(lines)
|
||||
if not summary:
|
||||
log.debug("No isCompactSummary entry found")
|
||||
return
|
||||
|
||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||
store_summary(api_key, summary, user_id, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
@@ -18,8 +18,12 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -27,11 +31,25 @@ _handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-capture] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-capture] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_TAIL_LINES = 500
|
||||
MAX_USER_MESSAGES = 30
|
||||
MAX_BASH_COMMANDS = 20
|
||||
MAX_ASSISTANT_TEXT = 10000
|
||||
# session_state captures churn fast (active codebase, files in flight). Past
|
||||
# ~3 months they're stale noise. Durable facts (decisions, conventions) are
|
||||
# stored separately by the agent without an expiration_date.
|
||||
SESSION_STATE_EXPIRY_DAYS = 90
|
||||
|
||||
|
||||
def tail_lines(filepath: str, n: int) -> list[str]:
|
||||
@@ -149,8 +167,9 @@ def build_content(state: dict, source: str) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
|
||||
"""Store session state as a memory via the Mem0 REST API."""
|
||||
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
"messages": [
|
||||
{"role": "user", "content": content}
|
||||
@@ -159,7 +178,9 @@ def store_memory(api_key: str, content: str, user_id: str, source: str) -> bool:
|
||||
"metadata": {
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
},
|
||||
"expiration_date": expires,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
@@ -207,7 +228,8 @@ def main():
|
||||
log.debug("No transcript_path provided")
|
||||
return
|
||||
|
||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -228,7 +250,7 @@ def main():
|
||||
len(state["bash_commands"]),
|
||||
)
|
||||
|
||||
store_memory(api_key, content, user_id, source)
|
||||
store_memory(api_key, content, user_id, source, session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
# the full context before it gets compressed.
|
||||
#
|
||||
# Output: Text instructions injected into Claude's context.
|
||||
# Claude still has the full conversation and can write an accurate summary.
|
||||
# A companion Python script (on_pre_compact.py) also runs to capture
|
||||
# transcript state directly via the Mem0 REST API as a safety net.
|
||||
# Claude still has the full conversation and can write an accurate summary,
|
||||
# which it stores via add_memory(infer=False) so the platform preserves
|
||||
# the structure verbatim instead of running a second extraction pass.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
## CRITICAL: Pre-Compaction Session Summary
|
||||
|
||||
@@ -18,7 +22,9 @@ Context compaction is about to happen. You are about to lose most of your conver
|
||||
|
||||
### Step 1: Store session summary
|
||||
|
||||
Call `add_memory` with a thorough summary covering ALL of the following:
|
||||
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
|
||||
|
||||
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
|
||||
|
||||
```
|
||||
## Session Summary (Pre-Compaction)
|
||||
@@ -44,11 +50,19 @@ Call `add_memory` with a thorough summary covering ALL of the following:
|
||||
the post-compaction agent continue without asking redundant questions]
|
||||
```
|
||||
|
||||
Include metadata: `{"type": "session_state", "source": "pre-compaction"}`
|
||||
Tool call shape:
|
||||
```
|
||||
add_memory(
|
||||
messages=[{"role":"user","content":"<the summary above>"}],
|
||||
user_id="<the active user_id from the SessionStart bootstrap>",
|
||||
metadata={"type":"session_state","source":"pre-compaction"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
### Step 2: Store any unstored learnings
|
||||
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories:
|
||||
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
|
||||
- Failed approaches -> metadata `{"type": "anti_pattern"}`
|
||||
- Successful strategies -> metadata `{"type": "task_learning"}`
|
||||
- Architecture decisions -> metadata `{"type": "decision"}`
|
||||
|
||||
@@ -11,9 +11,34 @@
|
||||
# even if jq is missing or stdin is malformed.
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
# Skip the bootstrap entirely if no API key is configured -- the agent
|
||||
# would otherwise be told to call mem0 MCP tools that will all fail.
|
||||
if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Identity line is emitted before every bootstrap variant so the agent
|
||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||
# than what the hooks write to.
|
||||
echo "## Mem0 Identity"
|
||||
echo ""
|
||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo ""
|
||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||
echo ""
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Bootstrap
|
||||
@@ -40,14 +65,22 @@ Continue where you left off.
|
||||
EOF
|
||||
|
||||
elif [ "$SOURCE" = "compact" ]; then
|
||||
# Capture the just-generated compact summary in the background.
|
||||
# PreCompact fires too early to see this entry; SessionStart-compact
|
||||
# is the first place isCompactSummary=true is in the transcript.
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
|
||||
|
||||
cat <<'EOF'
|
||||
## Mem0 Post-Compaction Recovery
|
||||
|
||||
Context was just compacted. You may have lost important session context.
|
||||
Context was just compacted. The Claude Code-generated compact summary
|
||||
is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||
|
||||
1. Call `search_memories` with queries related to what you were working on to reload relevant knowledge.
|
||||
2. Check for any session state memories that were saved before compaction.
|
||||
3. Continue working based on the recovered context.
|
||||
1. Call `search_memories` to reload context, layering up to three angles:
|
||||
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
|
||||
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
|
||||
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
|
||||
2. Continue working from the recovered context.
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
# must never block the user's prompt.
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""' 2>/dev/null || echo "")
|
||||
|
||||
@@ -26,7 +30,10 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||
|
||||
cat <<EOF
|
||||
## Memory check
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace mem0's default category taxonomy with one tuned for coding workflows.
|
||||
|
||||
mem0 auto-tags every memory with one or more `categories`. By default the list
|
||||
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
|
||||
This script replaces the project's category list with a coding-focused one.
|
||||
|
||||
The change is project-level (per the platform docs, per-request overrides are
|
||||
not supported on the managed API). Run once per project; future memories will
|
||||
be tagged using the new list automatically.
|
||||
|
||||
Usage:
|
||||
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
|
||||
python setup_coding_categories.py --apply # actually call project.update()
|
||||
|
||||
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
CODING_CATEGORIES = [
|
||||
{
|
||||
"architecture_decisions": (
|
||||
"Design choices, system structure, technology selection, trade-offs evaluated, "
|
||||
"and architectural patterns adopted in the project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"anti_patterns": (
|
||||
"Approaches that failed, debugging dead-ends, common mistakes to avoid, "
|
||||
"and lessons learned from things that didn't work."
|
||||
)
|
||||
},
|
||||
{
|
||||
"task_learnings": (
|
||||
"Strategies and approaches that succeeded for specific tasks, including tooling "
|
||||
"tricks, workflow shortcuts, and effective problem-solving patterns."
|
||||
)
|
||||
},
|
||||
{
|
||||
"tooling_setup": (
|
||||
"Development environment, build tools, dependencies, package managers, deploy "
|
||||
"pipelines, and configuration steps for the project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"bug_fixes": (
|
||||
"Specific bug fixes with root cause analysis, the fix applied, and how the bug "
|
||||
"was diagnosed -- useful for recognising similar issues later."
|
||||
)
|
||||
},
|
||||
{
|
||||
"coding_conventions": (
|
||||
"Code style, naming patterns, file organisation, error-handling conventions, "
|
||||
"and team agreements about how code is written in this project."
|
||||
)
|
||||
},
|
||||
{
|
||||
"user_preferences": (
|
||||
"User's stated preferences for tools, libraries, languages, formatting, "
|
||||
"and ways of working."
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _print_categories(label: str, cats):
|
||||
print(f"=== {label} ===")
|
||||
if cats:
|
||||
print(json.dumps(cats, indent=2))
|
||||
else:
|
||||
print("(none / using mem0 defaults)")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument(
|
||||
"--apply",
|
||||
action="store_true",
|
||||
help="Actually call project.update(). Without this flag, runs in dry-run mode.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.environ.get("MEM0_API_KEY"):
|
||||
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from mem0 import MemoryClient
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR: the mem0ai Python SDK is not installed.\n"
|
||||
"Install with: pip install mem0ai\n"
|
||||
"Then re-run this script.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
client = MemoryClient()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"ERROR initialising MemoryClient: {e}\n"
|
||||
"Most commonly this is an invalid MEM0_API_KEY -- check the key at "
|
||||
"https://app.mem0.ai/dashboard/api-keys",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
current = client.project.get(fields=["custom_categories"])
|
||||
current_cats = current.get("custom_categories") if isinstance(current, dict) else None
|
||||
except Exception as e:
|
||||
print(f"ERROR fetching current categories: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_print_categories("Current project categories", current_cats)
|
||||
_print_categories("Proposed coding categories", CODING_CATEGORIES)
|
||||
|
||||
if not args.apply:
|
||||
print("Dry-run only -- no changes made. Re-run with --apply to write.")
|
||||
return 0
|
||||
|
||||
print("Applying coding categories...")
|
||||
try:
|
||||
response = client.project.update(custom_categories=CODING_CATEGORIES)
|
||||
except Exception as e:
|
||||
print(f"ERROR applying update: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Done.", response if response else "")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -97,8 +97,47 @@ Extract key learnings and store them using the `add_memory` tool:
|
||||
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
|
||||
- **Conventions established** -> Include metadata `{"type": "convention"}`
|
||||
|
||||
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
|
||||
|
||||
### Expiration: high-churn vs durable
|
||||
|
||||
Some memory types are state snapshots that go stale fast; others are durable facts that should outlive the session that created them. Mark the difference with `expiration_date` on writes.
|
||||
|
||||
| Type | Expiration | Why |
|
||||
|---|---|---|
|
||||
| `session_state`, `compact_summary` | `expiration_date` ≈ today + 90 days | Describe a single moment of project state. Useless after a quarter; clutter the recall surface. |
|
||||
| `decision`, `anti_pattern`, `convention`, `user_preference`, `task_learning`, `environmental` | omit `expiration_date` | Durable facts. A decision made last year is still a decision; same for a convention or a user preference. |
|
||||
|
||||
`add_memory` accepts `expiration_date` as a string (`"YYYY-MM-DD"`). The two server-side hooks (`on_pre_compact.py`, `capture_compact_summary.py`) already set this for the types they write. When you write directly via the MCP tool, follow the same rule.
|
||||
|
||||
### Recency filter on recall
|
||||
|
||||
When the user is asking about *current* state ("where were we", "what's the active task", "the latest decision on X"), filter recall to recent memories so stale snapshots don't surface:
|
||||
|
||||
```python
|
||||
# Last 90 days only
|
||||
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
```
|
||||
|
||||
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
|
||||
|
||||
Memories can be as detailed as needed -- include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
### Use `infer=False` for already-structured content
|
||||
|
||||
When you've done the extraction work yourself — pre-compaction summaries, decisions, anti-patterns, conventions you've explicitly identified — pass `infer=False` so the platform stores your text verbatim instead of running a second extraction pass over it.
|
||||
|
||||
```python
|
||||
add_memory(
|
||||
messages=[{"role": "user", "content": "<your structured fact>"}],
|
||||
user_id="<active user_id>",
|
||||
metadata={"type": "decision"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
Stick to one mode per distinct piece of content — don't mix `infer=True` (default) and `infer=False` for the same fact, you'll get duplicates. Default (`infer=True`) is right for raw conversational signal you want extracted; `infer=False` is right for pre-extracted structure.
|
||||
|
||||
## Before losing context
|
||||
|
||||
If context is about to be compacted or the session is ending, store a comprehensive session summary:
|
||||
|
||||
@@ -154,3 +154,5 @@ known-first-party = ["mem0", "mem0_cli"]
|
||||
profile = "black"
|
||||
known_first_party = ["mem0", "mem0_cli"]
|
||||
# isort scope kept aligned with [tool.ruff.lint.isort] above.
|
||||
# black-equivalent profile here matches the formatter behaviour ruff applies.
|
||||
# Plugin-version bumps need a touch here to fire required CI checks (path-filter trap).
|
||||
|
||||
Executable
+1195
File diff suppressed because it is too large
Load Diff
@@ -184,3 +184,27 @@ async def require_auth(
|
||||
return default_user
|
||||
raise HTTPException(status_code=401, detail="Authentication required.")
|
||||
return user
|
||||
|
||||
|
||||
def _ensure_admin(request: Request, user: User | None) -> None:
|
||||
"""Single source of truth for admin gating.
|
||||
|
||||
Allows: legacy ADMIN_API_KEY env (X-API-Key header), AUTH_DISABLED=true,
|
||||
or a registered User with role == 'admin'. Raises 403 otherwise.
|
||||
|
||||
Pure function — reusable from FastAPI deps and inline route guards.
|
||||
"""
|
||||
auth_type = getattr(request.state, "auth_type", "none")
|
||||
if auth_type in {"admin_api_key", "disabled"}:
|
||||
return
|
||||
if user is not None and user.role == "admin":
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Admin role required.")
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
user: User | None = Depends(verify_auth),
|
||||
) -> None:
|
||||
"""FastAPI dependency. Admin-only gate; returns nothing."""
|
||||
_ensure_admin(request, user)
|
||||
|
||||
+14
-6
@@ -13,7 +13,7 @@ from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth
|
||||
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, _ensure_admin, require_admin, verify_auth
|
||||
from errors import (
|
||||
UpstreamError,
|
||||
install_request_id_logging,
|
||||
@@ -301,7 +301,7 @@ async def log_requests(request: Request, call_next):
|
||||
|
||||
|
||||
@app.get("/configure", summary="Get current Mem0 configuration")
|
||||
def get_config(_auth=Depends(verify_auth)):
|
||||
def get_config(_admin=Depends(require_admin)):
|
||||
return _redact_config(get_current_config())
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ def list_bundled_providers(_auth=Depends(verify_auth)):
|
||||
|
||||
|
||||
@app.post("/configure", summary="Configure Mem0")
|
||||
def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)):
|
||||
def set_config(config: Dict[str, Any], _admin=Depends(require_admin)):
|
||||
"""Set memory configuration."""
|
||||
_validate_bundled_providers(config)
|
||||
update_config(config)
|
||||
@@ -386,19 +386,27 @@ def _list_all_memories(limit: int = ALL_MEMORIES_LIMIT) -> Dict[str, Any]:
|
||||
|
||||
@app.get("/memories", summary="Get memories")
|
||||
def get_all_memories(
|
||||
request: Request,
|
||||
user_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
_auth=Depends(verify_auth),
|
||||
user: User | None = Depends(verify_auth),
|
||||
):
|
||||
"""Retrieve stored memories. Lists all memories when no identifier is provided."""
|
||||
"""Retrieve stored memories. Lists all memories when no identifier is provided.
|
||||
|
||||
Note: the unfiltered listing branch is admin-only — it would otherwise leak
|
||||
every payload in the vector store across tenants once multi-user lands.
|
||||
Filtered queries remain available to any authenticated caller."""
|
||||
try:
|
||||
if not any([user_id, run_id, agent_id]):
|
||||
_ensure_admin(request, user)
|
||||
return _list_all_memories()
|
||||
filters = {
|
||||
k: v for k, v in {"user_id": user_id, "run_id": run_id, "agent_id": agent_id}.items() if v is not None
|
||||
}
|
||||
return get_memory_instance().get_all(filters=filters)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise upstream_error()
|
||||
|
||||
@@ -473,7 +481,7 @@ def delete_all_memories(
|
||||
|
||||
|
||||
@app.post("/reset", summary="Reset all memories")
|
||||
def reset_memory(_auth=Depends(verify_auth)):
|
||||
def reset_memory(_admin=Depends(require_admin)):
|
||||
"""Completely reset stored memories."""
|
||||
try:
|
||||
get_memory_instance().reset()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Literal, Optional
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import verify_auth
|
||||
from auth import require_admin
|
||||
from errors import upstream_error
|
||||
from schemas import MessageResponse
|
||||
from server_state import get_memory_instance
|
||||
@@ -42,7 +42,7 @@ def _parse_timestamp(value: Any) -> Optional[datetime]:
|
||||
|
||||
|
||||
@router.get("", response_model=list[Entity])
|
||||
def list_entities(_auth=Depends(verify_auth)):
|
||||
def list_entities(_admin=Depends(require_admin)):
|
||||
buckets: dict[tuple[EntityType, str], dict[str, Any]] = defaultdict(
|
||||
lambda: {"total_memories": 0, "created_at": None, "updated_at": None}
|
||||
)
|
||||
@@ -69,7 +69,7 @@ def list_entities(_auth=Depends(verify_auth)):
|
||||
|
||||
|
||||
@router.delete("/{entity_type}/{entity_id}", response_model=MessageResponse)
|
||||
def delete_entity(entity_type: EntityType, entity_id: str, _auth=Depends(verify_auth)):
|
||||
def delete_entity(entity_type: EntityType, entity_id: str, _admin=Depends(require_admin)):
|
||||
try:
|
||||
get_memory_instance().delete_all(**{TYPE_TO_FIELD[entity_type]: entity_id})
|
||||
except Exception:
|
||||
|
||||
@@ -6,9 +6,9 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import require_auth
|
||||
from auth import require_admin
|
||||
from db import get_db
|
||||
from models import RequestLog, User
|
||||
from models import RequestLog
|
||||
|
||||
router = APIRouter(prefix="/requests", tags=["requests"])
|
||||
|
||||
@@ -30,7 +30,7 @@ API_KEY_AUTH_TYPES = ("api_key", "admin_api_key")
|
||||
|
||||
@router.get("", response_model=list[RequestLogItem])
|
||||
def list_requests(
|
||||
user: User = Depends(require_auth),
|
||||
_admin=Depends(require_admin),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from hashlib import sha256
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "oss-to-platform-migrate.sh"
|
||||
|
||||
|
||||
class MigrationHTTPServer:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ping_emails: dict[str, str] | None = None,
|
||||
verify_api_key: str = "verified-key",
|
||||
verify_status: int = 200,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
qdrant_collection: str = "mem0",
|
||||
qdrant_pages: list[dict[str, Any]] | None = None,
|
||||
platform_memories: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.ping_emails = ping_emails or {}
|
||||
self.verify_api_key = verify_api_key
|
||||
self.verify_status = verify_status
|
||||
self.qdrant_api_key = qdrant_api_key
|
||||
self.qdrant_collection = qdrant_collection
|
||||
self.qdrant_pages = qdrant_pages or [{"points": [], "next_page_offset": None}]
|
||||
self.platform_memories = platform_memories or []
|
||||
self.requests: list[dict[str, Any]] = []
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
||||
self.url = f"http://127.0.0.1:{self._server.server_port}"
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
|
||||
def __enter__(self) -> "MigrationHTTPServer":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def _handler(self) -> type[BaseHTTPRequestHandler]:
|
||||
owner = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
def _read_json(self) -> dict[str, Any]:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _record(self, body: dict[str, Any] | None = None) -> None:
|
||||
owner.requests.append(
|
||||
{
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"headers": dict(self.headers),
|
||||
"body": body or {},
|
||||
}
|
||||
)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._record()
|
||||
if self.path == "/v1/ping/":
|
||||
auth = self.headers.get("Authorization", "")
|
||||
token = auth.removeprefix("Token ")
|
||||
email = owner.ping_emails.get(token)
|
||||
if email:
|
||||
self._send_json(200, {"user_email": email})
|
||||
else:
|
||||
self._send_json(401, {"detail": "Invalid token"})
|
||||
return
|
||||
if self.path == f"/collections/{owner.qdrant_collection}":
|
||||
if self.headers.get("api-key") != owner.qdrant_api_key:
|
||||
self._send_json(401, {"status": {"error": "unauthorized"}})
|
||||
else:
|
||||
self._send_json(200, {"result": {"status": "green"}, "status": "ok"})
|
||||
return
|
||||
self._send_json(404, {"detail": "Not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
body = self._read_json()
|
||||
self._record(body)
|
||||
parsed = urlparse(self.path)
|
||||
if self.path == "/posthog":
|
||||
self._send_json(200, {"ok": True})
|
||||
return
|
||||
if self.path == "/api/v1/auth/email_code/":
|
||||
self._send_json(200, {"sent": True})
|
||||
return
|
||||
if self.path == "/api/v1/auth/email_code/verify/":
|
||||
if owner.verify_status != 200:
|
||||
self._send_json(owner.verify_status, {"error": "bad verification code"})
|
||||
else:
|
||||
self._send_json(200, {"api_key": owner.verify_api_key})
|
||||
return
|
||||
if self.path == f"/collections/{owner.qdrant_collection}/points/scroll":
|
||||
if self.headers.get("api-key") != owner.qdrant_api_key:
|
||||
self._send_json(401, {"status": {"error": "unauthorized"}})
|
||||
return
|
||||
offset = body.get("offset")
|
||||
page_index = int(offset) if offset is not None else 0
|
||||
page = owner.qdrant_pages[page_index]
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"result": {
|
||||
"points": page["points"],
|
||||
"next_page_offset": page.get("next_page_offset"),
|
||||
},
|
||||
"status": "ok",
|
||||
},
|
||||
)
|
||||
return
|
||||
if parsed.path == "/v3/memories/":
|
||||
query = parse_qs(parsed.query)
|
||||
page = int(query.get("page", ["1"])[0])
|
||||
page_size = int(query.get("page_size", ["100"])[0])
|
||||
filters = body.get("filters") if isinstance(body.get("filters"), dict) else {}
|
||||
filtered = owner.platform_memories
|
||||
for key in ("user_id", "agent_id", "run_id"):
|
||||
if key in filters:
|
||||
filtered = [memory for memory in filtered if memory.get(key) == filters[key]]
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
page_results = filtered[start:end]
|
||||
next_url = (
|
||||
f"{owner.url}/v3/memories/?page={page + 1}&page_size={page_size}"
|
||||
if end < len(filtered)
|
||||
else None
|
||||
)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"count": len(filtered),
|
||||
"next": next_url,
|
||||
"previous": None,
|
||||
"results": page_results,
|
||||
},
|
||||
)
|
||||
return
|
||||
if parsed.path == "/v3/memories/add/":
|
||||
memory_id = f"platform-{len(owner.platform_memories) + 1}"
|
||||
message = (body.get("messages") or [{}])[0]
|
||||
memory = {
|
||||
"id": memory_id,
|
||||
"memory": message.get("content"),
|
||||
"metadata": body.get("metadata"),
|
||||
"user_id": body.get("user_id"),
|
||||
"agent_id": body.get("agent_id"),
|
||||
"run_id": body.get("run_id"),
|
||||
}
|
||||
owner.platform_memories.append(memory)
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"message": "Memories stored successfully",
|
||||
"status": "SUCCEEDED",
|
||||
"event_id": "event-1",
|
||||
"results": [{"id": memory_id, "data": {"memory": message.get("content")}, "event": "ADD"}],
|
||||
},
|
||||
)
|
||||
return
|
||||
self._send_json(404, {"detail": "Not found"})
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def alias_marker(anon_id: str, email: str) -> str:
|
||||
return sha256(f"{anon_id}\0{email}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def write_config(mem0_dir: Path, data: dict[str, Any]) -> None:
|
||||
mem0_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mem0_dir / "config.json").write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
|
||||
def read_config(mem0_dir: Path) -> dict[str, Any]:
|
||||
return json.loads((mem0_dir / "config.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def run_migration_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
raw_config: str | None = None,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
if raw_config is not None:
|
||||
mem0_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mem0_dir / "config.json").write_text(raw_config, encoding="utf-8")
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(SCRIPT), "--auth-only", "--base-url", server.url, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir
|
||||
|
||||
|
||||
def run_export_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
output_path = tmp_path / "export.json"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"QDRANT_API_KEY": qdrant_api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--export-only",
|
||||
"--qdrant-url",
|
||||
server.url,
|
||||
"--qdrant-collection",
|
||||
server.qdrant_collection,
|
||||
"--output",
|
||||
str(output_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir, output_path
|
||||
|
||||
|
||||
def run_import_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
input_path: Path,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
api_key: str = "import-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"MEM0_API_KEY": api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--import-only",
|
||||
"--base-url",
|
||||
server.url,
|
||||
"--input",
|
||||
str(input_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir
|
||||
|
||||
|
||||
def run_full_script(
|
||||
tmp_path: Path,
|
||||
server: MigrationHTTPServer,
|
||||
*args: str,
|
||||
config: dict[str, Any] | None = None,
|
||||
qdrant_api_key: str = "qdrant-key",
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path]:
|
||||
mem0_dir = tmp_path / "mem0"
|
||||
output_path = tmp_path / "full-export.json"
|
||||
if config is not None:
|
||||
write_config(mem0_dir, config)
|
||||
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"MEM0_DIR": str(mem0_dir),
|
||||
"MEM0_MIGRATE_TELEMETRY_URL": f"{server.url}/posthog",
|
||||
"QDRANT_API_KEY": qdrant_api_key,
|
||||
}
|
||||
)
|
||||
env.pop("MEM0_API_KEY", None)
|
||||
env.pop("MEM0_BASE_URL", None)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(SCRIPT),
|
||||
"--base-url",
|
||||
server.url,
|
||||
"--qdrant-url",
|
||||
server.url,
|
||||
"--qdrant-collection",
|
||||
server.qdrant_collection,
|
||||
"--output",
|
||||
str(output_path),
|
||||
*args,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
return result, mem0_dir, output_path
|
||||
|
||||
|
||||
def posthog_events(server: MigrationHTTPServer) -> list[dict[str, Any]]:
|
||||
return [request["body"] for request in server.requests if request["path"] == "/posthog"]
|
||||
|
||||
|
||||
def test_existing_api_key_authenticates_and_stitches_ids(tmp_path: Path) -> None:
|
||||
config = {
|
||||
"user_id": "oss-123",
|
||||
"platform": {"api_key": "stored-key", "base_url": "https://api.mem0.ai"},
|
||||
"telemetry": {"anonymous_id": "cli-456"},
|
||||
}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"stored-key": "bob@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(tmp_path, server, "--yes", config=config)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as bob@example.com" in result.stdout
|
||||
assert not any(request["path"] == "/api/v1/auth/email_code/verify/" for request in server.requests)
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["platform"] == config["platform"]
|
||||
assert alias_marker("oss-123", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
assert alias_marker("cli-456", "bob@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.started" in event_names
|
||||
assert "oss.migrate.authenticated" in event_names
|
||||
assert event_names.count("$identify") == 2
|
||||
|
||||
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
|
||||
assert authenticated["distinct_id"] == "bob@example.com"
|
||||
assert authenticated["properties"]["local_anonymous_id"] == "oss-123"
|
||||
assert authenticated["properties"]["authenticated_email"] == "bob@example.com"
|
||||
|
||||
|
||||
def test_email_code_authenticates_without_persisting_credentials(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"Alice@Example.COM",
|
||||
"--code",
|
||||
"123456",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as alice@example.com" in result.stdout
|
||||
|
||||
verify_request = next(request for request in server.requests if request["path"] == "/api/v1/auth/email_code/verify/")
|
||||
assert verify_request["body"] == {"email": "alice@example.com", "code": "123456"}
|
||||
assert not any(request["path"] == "/api/v1/auth/email_code/" for request in server.requests)
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert "api_key" not in updated.get("platform", {})
|
||||
assert "user_email" not in updated.get("platform", {})
|
||||
assert updated["user_id"]
|
||||
assert alias_marker(updated["user_id"], "alice@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
events = posthog_events(server)
|
||||
assert [event["event"] for event in events].count("$identify") == 1
|
||||
authenticated = next(event for event in events if event["event"] == "oss.migrate.authenticated")
|
||||
assert authenticated["properties"]["auth_method"] == "email_code"
|
||||
|
||||
|
||||
def test_invalid_stored_key_falls_back_to_email_code(tmp_path: Path) -> None:
|
||||
config = {
|
||||
"user_id": "oss-fallback",
|
||||
"platform": {"api_key": "bad-key", "base_url": "https://api.mem0.ai"},
|
||||
}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "new@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"new@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Stored Mem0 Platform API key is invalid or expired" in result.stdout
|
||||
assert "Authenticated as new@example.com" in result.stdout
|
||||
|
||||
ping_tokens = [
|
||||
request["headers"]["Authorization"].removeprefix("Token ")
|
||||
for request in server.requests
|
||||
if request["path"] == "/v1/ping/"
|
||||
]
|
||||
assert ping_tokens == ["bad-key", "verified-key"]
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["platform"] == config["platform"]
|
||||
assert alias_marker("oss-fallback", "new@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_email_code_failure_reports_failed_telemetry(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(verify_status=400) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"fail@example.com",
|
||||
"--code",
|
||||
"bad",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Verification failed: bad verification code" in result.stderr
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert "telemetry" not in updated or "aliased_pairs" not in updated["telemetry"]
|
||||
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.started" in event_names
|
||||
assert "oss.migrate.failed" in event_names
|
||||
assert "$identify" not in event_names
|
||||
failed = next(event for event in events if event["event"] == "oss.migrate.failed")
|
||||
assert "Verification failed" in failed["properties"]["error"]
|
||||
|
||||
|
||||
def test_malformed_config_does_not_crash_and_authenticates(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "malformed@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"malformed@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
raw_config="{not valid json",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Authenticated as malformed@example.com" in result.stdout
|
||||
|
||||
updated = read_config(mem0_dir)
|
||||
assert updated["user_id"]
|
||||
assert alias_marker(updated["user_id"], "malformed@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_weird_telemetry_shape_does_not_crash(tmp_path: Path) -> None:
|
||||
config = {"user_id": "oss-weird-telemetry", "telemetry": "not-an-object"}
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "weird@example.com"}) as server:
|
||||
result, mem0_dir = run_migration_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"weird@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
config=config,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
updated = read_config(mem0_dir)
|
||||
assert isinstance(updated["telemetry"], dict)
|
||||
assert alias_marker("oss-weird-telemetry", "weird@example.com") in updated["telemetry"]["aliased_pairs"]
|
||||
|
||||
|
||||
def test_missing_python3_prints_clear_shell_error(tmp_path: Path) -> None:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
["/bin/bash", str(SCRIPT), "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "python3 is required to run the Mem0 migration" in result.stderr
|
||||
|
||||
|
||||
def test_curl_piped_help_works() -> None:
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f"curl -fsSL file://{SCRIPT} | bash -s -- --help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Migrate Python OSS hosted-Qdrant memories" in result.stdout
|
||||
|
||||
|
||||
def test_export_qdrant_memories_to_json_without_vectors_or_api_key(tmp_path: Path) -> None:
|
||||
pages = [
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-1",
|
||||
"vector": [0.1, 0.2],
|
||||
"payload": {
|
||||
"data": "User likes dark mode",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
"updated_at": "2026-05-01T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
"agent_id": "agent-1",
|
||||
"run_id": "run-1",
|
||||
"actor_id": "actor-1",
|
||||
"role": "user",
|
||||
"topic": "preferences",
|
||||
"text_lemmatized": "user like dark mode",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": 1,
|
||||
},
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-2",
|
||||
"vector": [0.3, 0.4],
|
||||
"payload": {
|
||||
"data": "User prefers concise answers",
|
||||
"hash": "hash-2",
|
||||
"user_id": "alice",
|
||||
"metadata_note": "extra",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": None,
|
||||
},
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(qdrant_pages=pages) as server:
|
||||
result, _mem0_dir, output_path = run_export_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--user-id",
|
||||
"alice",
|
||||
"--qdrant-page-size",
|
||||
"1",
|
||||
config={"user_id": "oss-export-user"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Exported 2 memories" in result.stdout
|
||||
|
||||
artifact = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert artifact["kind"] == "mem0_oss_qdrant_export"
|
||||
assert artifact["source"]["sdk"] == "python"
|
||||
assert artifact["source"]["vector_store"] == "qdrant"
|
||||
assert artifact["source"]["storage"] == "hosted"
|
||||
assert artifact["source"]["filters"]["user_id"] == "alice"
|
||||
assert artifact["record_count"] == 2
|
||||
assert artifact["local_anonymous_id"] == "oss-export-user"
|
||||
|
||||
first = artifact["records"][0]
|
||||
assert first["id"] == "point-1"
|
||||
assert first["memory"] == "User likes dark mode"
|
||||
assert first["hash"] == "hash-1"
|
||||
assert first["user_id"] == "alice"
|
||||
assert first["agent_id"] == "agent-1"
|
||||
assert first["run_id"] == "run-1"
|
||||
assert first["actor_id"] == "actor-1"
|
||||
assert first["role"] == "user"
|
||||
assert first["metadata"] == {"topic": "preferences"}
|
||||
assert all("vector" not in record for record in artifact["records"])
|
||||
assert "qdrant-key" not in output_path.read_text(encoding="utf-8")
|
||||
|
||||
scroll_requests = [request for request in server.requests if request["path"].endswith("/points/scroll")]
|
||||
assert len(scroll_requests) == 2
|
||||
assert scroll_requests[0]["body"]["with_vector"] is False
|
||||
assert scroll_requests[0]["body"]["filter"] == {"must": [{"key": "user_id", "match": {"value": "alice"}}]}
|
||||
assert scroll_requests[1]["body"]["offset"] == 1
|
||||
|
||||
|
||||
def test_export_requires_scope_or_all(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer() as server:
|
||||
result, _mem0_dir, output_path = run_export_script(tmp_path, server)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Export requires --user-id, --agent-id, --run-id, or --all" in result.stderr
|
||||
assert not output_path.exists()
|
||||
assert not any(request["path"].endswith("/points/scroll") for request in server.requests)
|
||||
|
||||
|
||||
def test_export_all_uses_no_qdrant_filter(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(qdrant_pages=[{"points": [], "next_page_offset": None}]) as server:
|
||||
result, _mem0_dir, output_path = run_export_script(tmp_path, server, "--all")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
artifact = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
assert artifact["record_count"] == 0
|
||||
assert artifact["records"] == []
|
||||
|
||||
scroll_request = next(request for request in server.requests if request["path"].endswith("/points/scroll"))
|
||||
assert "filter" not in scroll_request["body"]
|
||||
|
||||
|
||||
def test_export_invalid_qdrant_credentials_fail_clearly(tmp_path: Path) -> None:
|
||||
with MigrationHTTPServer(qdrant_api_key="correct-key") as server:
|
||||
result, _mem0_dir, output_path = run_export_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--user-id",
|
||||
"alice",
|
||||
qdrant_api_key="wrong-key",
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "Qdrant authentication failed" in result.stderr
|
||||
assert not output_path.exists()
|
||||
|
||||
|
||||
def test_import_platform_memories_from_export_json(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
input_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"},
|
||||
"records": [
|
||||
{
|
||||
"id": "local-1",
|
||||
"memory": "User likes barbecue",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
"metadata": {"topic": "food"},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 1" in result.stdout
|
||||
assert "Failed: 0" in result.stdout
|
||||
|
||||
add_request = next(request for request in server.requests if request["path"] == "/v3/memories/add/")
|
||||
body = add_request["body"]
|
||||
assert body["messages"] == [{"role": "user", "content": "User likes barbecue"}]
|
||||
assert body["user_id"] == "alice"
|
||||
assert body["infer"] is False
|
||||
assert body["source"] == "migration"
|
||||
assert body["timestamp"] == 1778112000
|
||||
assert body["metadata"]["topic"] == "food"
|
||||
assert body["metadata"]["mem0_migration_source"] == "python_oss_qdrant"
|
||||
assert body["metadata"]["mem0_migration_collection"] == "mem0_test"
|
||||
assert body["metadata"]["mem0_migration_local_id"] == "local-1"
|
||||
assert body["metadata"]["mem0_migration_local_hash"] == "hash-1"
|
||||
|
||||
events = posthog_events(server)
|
||||
assert "oss.migrate.completed" in [event["event"] for event in events]
|
||||
|
||||
|
||||
def test_import_skips_existing_identical_memory(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
|
||||
record = {"id": "local-1", "memory": "User likes barbecue", "hash": "hash-1", "user_id": "alice"}
|
||||
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
|
||||
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
|
||||
|
||||
existing = [
|
||||
{
|
||||
"id": "platform-1",
|
||||
"memory": "User likes barbecue",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"mem0_migration_import_key": import_key,
|
||||
"mem0_migration_local_hash": "hash-1",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 0" in result.stdout
|
||||
assert "Skipped existing identical: 1" in result.stdout
|
||||
assert "Changed existing: 0" in result.stdout
|
||||
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
|
||||
|
||||
def test_import_reports_changed_existing_without_update_or_add(tmp_path: Path) -> None:
|
||||
input_path = tmp_path / "import.json"
|
||||
source = {"sdk": "python", "vector_store": "qdrant", "collection": "mem0_test"}
|
||||
record = {"id": "local-1", "memory": "User likes brisket", "hash": "hash-new", "user_id": "alice"}
|
||||
input_path.write_text(json.dumps({"source": source, "records": [record]}), encoding="utf-8")
|
||||
import_key = sha256("python:qdrant:mem0_test:local-1".encode("utf-8")).hexdigest()
|
||||
|
||||
existing = [
|
||||
{
|
||||
"id": "platform-1",
|
||||
"memory": "User likes barbecue",
|
||||
"user_id": "alice",
|
||||
"metadata": {
|
||||
"mem0_migration_import_key": import_key,
|
||||
"mem0_migration_local_hash": "hash-old",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"import-key": "alice@example.com"}, platform_memories=existing) as server:
|
||||
result, _mem0_dir = run_import_script(tmp_path, server, input_path)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Imported: 0" in result.stdout
|
||||
assert "Skipped existing identical: 0" in result.stdout
|
||||
assert "Changed existing: 1" in result.stdout
|
||||
assert not any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
review_path_line = next(line for line in result.stdout.splitlines() if line.startswith("Review file: "))
|
||||
review_path = Path(review_path_line.removeprefix("Review file: "))
|
||||
review = json.loads(review_path.read_text(encoding="utf-8"))
|
||||
assert review["records"][0]["status"] == "changed_existing"
|
||||
assert review["records"][0]["platform_memory_id"] == "platform-1"
|
||||
|
||||
|
||||
def test_full_flow_auth_export_and_imports_memories(tmp_path: Path) -> None:
|
||||
qdrant_pages = [
|
||||
{
|
||||
"points": [
|
||||
{
|
||||
"id": "point-1",
|
||||
"payload": {
|
||||
"data": "User likes barbecue",
|
||||
"hash": "hash-1",
|
||||
"created_at": "2026-05-07T00:00:00Z",
|
||||
"user_id": "alice",
|
||||
},
|
||||
}
|
||||
],
|
||||
"next_page_offset": None,
|
||||
}
|
||||
]
|
||||
|
||||
with MigrationHTTPServer(ping_emails={"verified-key": "alice@example.com"}, qdrant_pages=qdrant_pages) as server:
|
||||
result, _mem0_dir, output_path = run_full_script(
|
||||
tmp_path,
|
||||
server,
|
||||
"--email",
|
||||
"alice@example.com",
|
||||
"--code",
|
||||
"123456",
|
||||
"--user-id",
|
||||
"alice",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Phase 1/3: Authenticate with Mem0 Platform" in result.stdout
|
||||
assert "Phase 2/3: Export Python OSS memories from hosted Qdrant" in result.stdout
|
||||
assert "Phase 3/3: Import memories into Mem0 Platform" in result.stdout
|
||||
assert "Imported: 1" in result.stdout
|
||||
assert output_path.exists()
|
||||
assert any(request["path"] == "/v3/memories/add/" for request in server.requests)
|
||||
events = posthog_events(server)
|
||||
event_names = [event["event"] for event in events]
|
||||
assert "oss.migrate.authenticated" in event_names
|
||||
assert "oss.migrate.completed" in event_names
|
||||
Reference in New Issue
Block a user