diff --git a/docs/core-concepts/memory-operations/add.mdx b/docs/core-concepts/memory-operations/add.mdx index aa92dbeb7..23d9f772d 100644 --- a/docs/core-concepts/memory-operations/add.mdx +++ b/docs/core-concepts/memory-operations/add.mdx @@ -15,6 +15,7 @@ Adding memory is how Mem0 captures useful details from a conversation so your ag - **Infer**: Controls whether Mem0 extracts structured memories (`infer=True`, default) or stores raw messages. - **Metadata**: Optional filters (e.g., `{"category": "movie_recommendations"}`) that improve retrieval later. - **User / Session identifiers**: `user_id`, `agent_id`, `app_id`, or `run_id` that scope the memory for future searches. +- **expiration_date**: Optional `YYYY-MM-DD` date after which the memory is treated as expired. Use `expirationDate` in the JavaScript SDKs. Expired memories are hidden from `search` and `get_all` unless you pass `show_expired` (`showExpired` in JavaScript); fetching by ID still returns them. ## How does it work? @@ -105,6 +106,9 @@ result = m.add(messages, user_id="alice", metadata={"category": "movie_recommend # Optionally store raw messages without inference result = m.add(messages, user_id="alice", metadata={"category": "movie_recommendations"}, infer=False) + +# Optionally set an expiration date (YYYY-MM-DD) +result = m.add(messages, user_id="alice", expiration_date="2030-01-31") ``` ```javascript JavaScript @@ -123,6 +127,12 @@ const result = memory.add(messages, { userId: "alice", metadata: { category: "preferences" } }); + +// Optionally set an expiration date (YYYY-MM-DD) +const expiring = memory.add(messages, { + userId: "alice", + expirationDate: "2030-01-31", +}); ``` diff --git a/docs/core-concepts/memory-operations/update.mdx b/docs/core-concepts/memory-operations/update.mdx index d8340c26a..7c5084caf 100644 --- a/docs/core-concepts/memory-operations/update.mdx +++ b/docs/core-concepts/memory-operations/update.mdx @@ -12,7 +12,7 @@ Mem0’s update operation lets you fix or enrich an existing memory without dele ## Key terms - **memory_id**: Unique identifier returned by `add` or `search` results. -- **text** / **data**: New content that replaces the stored memory value. +- **text**: New content that replaces the stored memory value. In the Python OSS SDK, `data` is a deprecated alias for `text`. - **metadata**: Optional key-value pairs you update alongside the text. - **timestamp**: Unix epoch (int/float) or ISO 8601 string to override the memory's timestamp. - **batch_update**: Platform API that edits multiple memories in a single request. @@ -110,17 +110,47 @@ from mem0 import Memory memory = Memory() +# Replace the content memory.update( memory_id="mem_123", - data="Alex now prefers decaf coffee", + text="Alex now prefers decaf coffee", +) + +# Update content plus metadata and an expiration date (None clears it) +memory.update( + memory_id="mem_123", + text="Alex now prefers decaf coffee", + metadata={"category": "preferences"}, + expiration_date="2030-01-31", ) ``` -``` + +```javascript JavaScript +import { Memory } from "mem0ai/oss"; + +const memory = new Memory(); + +// Replace the content +await memory.update("mem_123", { text: "Alex now prefers decaf coffee" }); + +// Update content plus metadata and an expiration date (null clears it) +await memory.update("mem_123", { + text: "Alex now prefers decaf coffee", + metadata: { category: "preferences" }, + expirationDate: "2030-01-31", +}); + +// Update metadata only, leaving the stored text untouched +await memory.update("mem_123", { metadata: { category: "preferences" } }); ``` - OSS JavaScript SDK does not expose `update` yet: use the REST API or Python SDK when self-hosting. + In both OSS SDKs the content is optional — pass only `metadata` and/or an expiration date to update those while keeping the existing content. At least one of the three must be provided, otherwise the call raises. + + + + `data` is a deprecated alias for `text` in both OSS SDKs (`data=` in Python, `{ data: ... }` in JavaScript). It still works but logs a warning; prefer `text`. In JavaScript, passing a bare string is shorthand for `{ text }`, so `update(memoryId, "new text")` also still works. ## Tips @@ -138,7 +168,7 @@ memory.update( | Capability | Mem0 Platform | Mem0 OSS | | --- | --- | --- | -| Update call | `client.update(memory_id, {...})` | `memory.update(memory_id, data=...)` | +| Update call | `client.update(memory_id, {...})` | `memory.update(memory_id, text=...)` | | Batch updates | `client.batch_update` (up to 1000 memories) | Script your own loop or bulk job | | Dashboard visibility | Inspect updates in the UI | Inspect via logs or custom tooling | | Immutable handling | Returns descriptive error | Raises exception: delete and re-add | diff --git a/docs/llms.txt b/docs/llms.txt index 9adece671..304eb60fc 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -61,7 +61,7 @@ client.get_all(user_id="alice") client.get(memory_id="") # Update -client.update(memory_id="", data="Alice loves mountain hiking") +client.update(memory_id="", text="Alice loves mountain hiking") # Delete client.delete(memory_id="") @@ -118,7 +118,7 @@ m.get_all(user_id="alice") m.get(memory_id="") # Update -m.update(memory_id="", data="Alice loves mountain hiking") +m.update(memory_id="", text="Alice loves mountain hiking") # Delete m.delete(memory_id="") diff --git a/docs/open-source/features/async-memory.mdx b/docs/open-source/features/async-memory.mdx index 333ab1895..b0bfface6 100644 --- a/docs/open-source/features/async-memory.mdx +++ b/docs/open-source/features/async-memory.mdx @@ -36,7 +36,7 @@ icon: "bolt" | Search memories | `await memory.search(...)` | Returns dict with `results`, identical shape. | | List memories | `await memory.get_all(...)` | Filter by `user_id`, `agent_id`, `run_id`. | | Retrieve memory | `await memory.get(memory_id=...)` | Raises `ValueError` if ID is invalid. | - | Update memory | `await memory.update(memory_id=..., data=...)` | Accepts partial updates. | + | Update memory | `await memory.update(memory_id=..., text=...)` | Accepts partial updates. | | Delete memory | `await memory.delete(memory_id=...)` | Returns confirmation payload. | | Delete in bulk | `await memory.delete_all(...)` | Requires at least one scope filter. | | History | `await memory.history(memory_id=...)` | Fetches change log for auditing. | @@ -185,7 +185,7 @@ specific_memory = await memory.get(memory_id="memory-id-here") # Update a memory updated_memory = await memory.update( memory_id="memory-id-here", - data="I'm travelling to Seattle" + text="I'm travelling to Seattle" ) # Delete a memory diff --git a/docs/platform/advanced-memory-operations.mdx b/docs/platform/advanced-memory-operations.mdx index c1cb8a689..dc43a55c3 100644 --- a/docs/platform/advanced-memory-operations.mdx +++ b/docs/platform/advanced-memory-operations.mdx @@ -129,7 +129,7 @@ matches = await memory.search( ```python await memory.update( memory_id=matches["results"][0]["id"], - data="Morgan avoids shellfish and prefers boutique hotels in central Tokyo.", + text="Morgan avoids shellfish and prefers boutique hotels in central Tokyo.", ) ``` diff --git a/mem0-ts/src/oss/README.md b/mem0-ts/src/oss/README.md index d30b1bb57..3c9ab6742 100644 --- a/mem0-ts/src/oss/README.md +++ b/mem0-ts/src/oss/README.md @@ -110,14 +110,42 @@ You only need to provide API keys - all other settings are optional. ### Methods - `add(messages: string | Message[], userId?: string, ...): Promise` + - Options include `metadata`, `infer`, and `expirationDate` (a `YYYY-MM-DD` date after which the memory is treated as expired). - `search(query: string, userId?: string, ...): Promise` + - Expired memories are omitted unless you pass `showExpired: true`. - `get(memoryId: string): Promise` -- `update(memoryId: string, data: string): Promise<{ message: string }>` + - Fetching by ID returns the memory even if it has expired. +- `getAll(options): Promise` + - Expired memories are omitted unless you pass `showExpired: true`. +- `update(memoryId: string, config: string | UpdateMemoryOptions): Promise<{ message: string }>` + - `UpdateMemoryOptions` is `{ text?, data?, metadata?, expirationDate? }`. At least one must be + provided; omitted fields are left untouched, and `expirationDate: null` clears an existing + expiry. `data` is a deprecated alias for `text`. + - Passing a bare string is shorthand for `{ text }`, so `update(memoryId, "new text")` still works. - `delete(memoryId: string): Promise<{ message: string }>` - `deleteAll(userId?: string, ...): Promise<{ message: string }>` - `history(memoryId: string): Promise` - `reset(): Promise` +```typescript +// Replace the content +await memory.update(memoryId, { text: "Alex now prefers decaf coffee" }); + +// Update metadata only, leaving the stored text untouched +await memory.update(memoryId, { metadata: { category: "preferences" } }); + +// Expire the memory on a given day, or clear an existing expiry +await memory.update(memoryId, { expirationDate: "2030-01-31" }); +await memory.update(memoryId, { expirationDate: null }); + +// Include expired memories in reads +await memory.getAll({ filters: { user_id: "alice" }, showExpired: true }); +await memory.search("coffee", { + filters: { user_id: "alice" }, + showExpired: true, +}); +``` + ### Try the Example We provide a comprehensive example in `examples/basic.ts` that demonstrates all the features including: diff --git a/mem0-ts/src/oss/examples/basic.ts b/mem0-ts/src/oss/examples/basic.ts index 28de83834..a43d95e27 100644 --- a/mem0-ts/src/oss/examples/basic.ts +++ b/mem0-ts/src/oss/examples/basic.ts @@ -73,10 +73,9 @@ async function runTests(memory: Memory) { } // Updating this memory - const result4 = await memory.update( - result1.results[0].id, - "I love India, it is my favorite country.", - ); + const result4 = await memory.update(result1.results[0].id, { + text: "I love India, it is my favorite country.", + }); console.log("Updated memory:", result4); // Get all memories diff --git a/mem0-ts/src/oss/examples/utils/test-utils.ts b/mem0-ts/src/oss/examples/utils/test-utils.ts index 83c6d0913..65fafd40e 100644 --- a/mem0-ts/src/oss/examples/utils/test-utils.ts +++ b/mem0-ts/src/oss/examples/utils/test-utils.ts @@ -55,10 +55,9 @@ export async function runTests(memory: Memory) { } // Updating this memory - const result4 = await memory.update( - result1.results[0].id, - "I love India, it is my favorite country.", - ); + const result4 = await memory.update(result1.results[0].id, { + text: "I love India, it is my favorite country.", + }); console.log("Updated memory:", result4); // Get all memories diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index b571e3912..5d8aea211 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -36,6 +36,7 @@ import { SearchMemoryOptions, DeleteAllMemoryOptions, GetAllMemoryOptions, + UpdateMemoryOptions, UpdateProjectOptions, } from "./memory.types"; import { parse_vision_messages } from "../utils/memory"; @@ -72,6 +73,8 @@ import { ScoredResult, } from "../utils/scoring"; import { getDefaultVectorStoreDbPath } from "../utils/sqlite"; +import { logger } from "../utils/logger"; +import { normalizeExpirationDate, payloadIsExpired } from "../utils/expiration"; import { getOrCreateMem0UserId } from "../../../client/config"; // Entity params that must be passed via filters - check both snake_case and camelCase @@ -713,6 +716,11 @@ export class Memory { if (agentId) filters.agent_id = metadata.agent_id = agentId; if (runId) filters.run_id = metadata.run_id = runId; + // Normalize expiration date into the stored metadata (round-trips via get()). + if (config.expirationDate != null) { + metadata.expiration_date = normalizeExpirationDate(config.expirationDate); + } + if (!filters.user_id && !filters.agent_id && !filters.run_id) { throw new Error( "One of the filters: userId, agentId or runId is required!", @@ -1307,7 +1315,12 @@ export class Memory { : {}; await this._ensureInitialized(); - const { topK = 20, threshold = 0.1, explain = false } = config; + const { + topK = 20, + threshold = 0.1, + explain = false, + showExpired = false, + } = config; await this._captureEvent("search", { query_length: query.length, @@ -1475,11 +1488,13 @@ export class Memory { } // Step 7: Build candidate set from semantic results - const candidates = semanticResults.map((mem) => ({ - id: String(mem.id), - score: mem.score ?? 0, - payload: mem.payload || {}, - })); + const candidates = semanticResults + .filter((mem) => showExpired || !payloadIsExpired(mem.payload)) + .map((mem) => ({ + id: String(mem.id), + score: mem.score ?? 0, + payload: mem.payload || {}, + })); // Step 8: Score and rank const scoredResults = scoreAndRank( @@ -1563,11 +1578,49 @@ export class Memory { return result; } - async update(memoryId: string, data: string): Promise<{ message: string }> { + async update( + memoryId: string, + config: string | UpdateMemoryOptions, + ): Promise<{ message: string }> { await this._ensureInitialized(); await this._captureEvent("update", { memory_id: memoryId }); - const embedding = await this.embedder.embed(data); - await this.updateMemory(memoryId, data, { [data]: embedding }); + + const options: UpdateMemoryOptions = + typeof config === "string" ? { text: config } : config; + + const { data, metadata, expirationDate } = options; + let text = options.text; + + if (data != null) { + logger.warn( + "The `data` option of update() is deprecated and will be removed in " + + "the next major release. Use `text` instead.", + ); + if (text == null) { + text = data; + } + } + + if (text == null && metadata == null && expirationDate === undefined) { + throw new Error( + "At least one of text, metadata, or expirationDate must be provided.", + ); + } + + const updateMetadata: Record = { ...metadata }; + if (expirationDate !== undefined) { + updateMetadata.expiration_date = + expirationDate === null + ? null + : normalizeExpirationDate(expirationDate); + } + + const existingEmbeddings: Record = {}; + if (text != null) { + existingEmbeddings[text] = await this.embedder.embed(text); + } + + await this.updateMemory(memoryId, text, existingEmbeddings, updateMetadata); const result = { message: "Memory updated successfully!" }; await this._displayFirstRunNotice("update"); return result; @@ -1704,7 +1757,7 @@ export class Memory { await this._ensureInitialized(); - const { topK = 20 } = config; + const { topK = 20, showExpired = false } = config; // Validate and trim entity IDs in filters. Drop keys that resolve to // undefined so downstream vector stores don't receive @@ -1733,7 +1786,13 @@ export class Memory { ); } - const [memories] = await this.vectorStore.list(filters, topK); + // Over-fetch so expired memories dropped below still leave topK survivors. + const fetchLimit = showExpired ? topK : Math.max(topK * 4, 60); + const [memories] = await this.vectorStore.list(filters, fetchLimit); + + const visibleMemories = showExpired + ? memories + : memories.filter((mem) => !payloadIsExpired(mem.payload)); const excludedKeys = new Set([ "user_id", @@ -1746,7 +1805,7 @@ export class Memory { "textLemmatized", "attributedTo", ]); - const results = memories.map((mem) => ({ + const results = visibleMemories.slice(0, topK).map((mem) => ({ id: mem.id, memory: mem.payload.data, hash: mem.payload.hash, @@ -1807,7 +1866,7 @@ export class Memory { private async updateMemory( memoryId: string, - data: string, + data: string | undefined, existingEmbeddings: Record, metadata: Record = {}, ): Promise { @@ -1817,15 +1876,24 @@ export class Memory { } const prevValue = existingMemory.payload.data; + // Metadata-only update: fall back to the stored text so we can re-index it. + const newData = data ?? prevValue; + if (typeof newData !== "string") { + throw new Error( + `Memory with ID ${memoryId} does not have text content to update`, + ); + } + const textChanged = newData !== prevValue; + const embedding = - existingEmbeddings[data] || (await this.embedder.embed(data)); + existingEmbeddings[newData] || (await this.embedder.embed(newData)); const newMetadata = { ...existingMemory.payload, ...metadata, - data, - hash: createHash("md5").update(data).digest("hex"), - textLemmatized: lemmatizeForBm25(data), + data: newData, + hash: createHash("md5").update(newData).digest("hex"), + textLemmatized: lemmatizeForBm25(newData), createdAt: existingMemory.payload.createdAt, updatedAt: new Date().toISOString(), }; @@ -1834,20 +1902,22 @@ export class Memory { await this.db.addHistory( memoryId, prevValue, - data, + newData, "UPDATE", newMetadata.createdAt, newMetadata.updatedAt, ); - // Entity-store cleanup: strip this memory's id from old-text entities, - // then re-extract entities from the new text and link them back. - try { - const sessionFilters = this._sessionFiltersFromPayload(newMetadata); - await this._removeMemoryFromEntityStore(memoryId, sessionFilters); - await this._linkEntitiesForMemory(memoryId, data, sessionFilters); - } catch (e) { - console.warn(`Entity store cleanup/link failed during update: ${e}`); + // Entity-store cleanup only when the text changed: strip this memory's id + // from old-text entities, then re-extract from the new text and link back. + if (textChanged) { + try { + const sessionFilters = this._sessionFiltersFromPayload(newMetadata); + await this._removeMemoryFromEntityStore(memoryId, sessionFilters); + await this._linkEntitiesForMemory(memoryId, newData, sessionFilters); + } catch (e) { + console.warn(`Entity store cleanup/link failed during update: ${e}`); + } } return memoryId; diff --git a/mem0-ts/src/oss/src/memory/memory.types.ts b/mem0-ts/src/oss/src/memory/memory.types.ts index c125b61b3..861db25f7 100644 --- a/mem0-ts/src/oss/src/memory/memory.types.ts +++ b/mem0-ts/src/oss/src/memory/memory.types.ts @@ -12,6 +12,22 @@ export interface AddMemoryOptions extends Entity { filters?: SearchFilters; infer?: boolean; timestamp?: number | string | Date | null; + /** Date (YYYY-MM-DD) after which the memory is considered expired. */ + expirationDate?: string | null; +} + +export interface UpdateMemoryOptions { + /** New content to update the memory with. */ + text?: string; + /** + * New content to update the memory with. + * @deprecated Use `text` instead. Will be removed in the next major release. + */ + data?: string; + /** Metadata merged into the memory's existing metadata. */ + metadata?: Record; + /** Date (YYYY-MM-DD) after which the memory expires, or `null` to clear it. */ + expirationDate?: string | null; } export interface SearchMemoryOptions { @@ -20,11 +36,15 @@ export interface SearchMemoryOptions { threshold?: number; explain?: boolean; referenceDate?: number | string | Date | null; + /** Include expired memories in the results. Defaults to false. */ + showExpired?: boolean; } export interface GetAllMemoryOptions { topK?: number; filters?: SearchFilters; + /** Include expired memories in the results. Defaults to false. */ + showExpired?: boolean; } export interface DeleteAllMemoryOptions extends Entity {} diff --git a/mem0-ts/src/oss/src/utils/expiration.ts b/mem0-ts/src/oss/src/utils/expiration.ts new file mode 100644 index 000000000..ad4710ced --- /dev/null +++ b/mem0-ts/src/oss/src/utils/expiration.ts @@ -0,0 +1,52 @@ +/** + * Expiration date handling for memories. + * + * Memories may carry an `expiration_date` (YYYY-MM-DD) after which they are + * hidden from `getAll()` and `search()` unless `showExpired` is set. + */ + +const EXPIRATION_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +function todayUtc(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Normalize a user-supplied expiration date to a YYYY-MM-DD string. + * + * Deliberately stricter than `new Date(value)`, which accepts formats the + * Python SDK rejects ("12/31/2099", "2099") and resolves them against the + * local timezone, shifting the calendar day. It also silently rolls invalid + * dates over — `new Date("2099-02-30T00:00:00Z")` yields March 2nd. + */ +export function normalizeExpirationDate(value: string): string { + const match = EXPIRATION_DATE_PATTERN.exec(value); + if (match) { + const [, year, month, day] = match; + const parsed = new Date(`${value}T00:00:00Z`); + if ( + !Number.isNaN(parsed.getTime()) && + parsed.getUTCFullYear() === Number(year) && + parsed.getUTCMonth() === Number(month) - 1 && + parsed.getUTCDate() === Number(day) + ) { + return value; + } + } + throw new Error("expirationDate must be a valid date in YYYY-MM-DD format."); +} + +/** True when the payload carries an expiration date strictly before today (UTC). */ +export function payloadIsExpired( + payload: Record | null | undefined, +) { + const raw = payload?.expiration_date; + if (!raw) return false; + try { + // YYYY-MM-DD sorts lexicographically the same way it sorts chronologically. + return normalizeExpirationDate(String(raw)) < todayUtc(); + } catch { + // Unparseable stored value: treat as non-expiring rather than hiding data. + return false; + } +} diff --git a/mem0-ts/src/oss/tests/memory.crud.test.ts b/mem0-ts/src/oss/tests/memory.crud.test.ts index 55db5d5f4..de2565c16 100644 --- a/mem0-ts/src/oss/tests/memory.crud.test.ts +++ b/mem0-ts/src/oss/tests/memory.crud.test.ts @@ -5,6 +5,7 @@ /// import { Memory } from "../src/memory"; import type { MemoryItem, SearchResult } from "../src/types"; +import { logger } from "../src/utils/logger"; jest.setTimeout(30000); @@ -150,7 +151,7 @@ describe("Memory - update()", () => { infer: false, }); const id = addResult.results[0].id; - const result = await memory.update(id, "Updated"); + const result = await memory.update(id, { text: "Updated" }); expect(result.message).toBe("Memory updated successfully!"); }); @@ -160,7 +161,7 @@ describe("Memory - update()", () => { infer: false, }); const id = addResult.results[0].id; - await memory.update(id, "After update"); + await memory.update(id, { text: "After update" }); const item: MemoryItem | null = await memory.get(id); expect(item!.memory).toBe("After update"); }); @@ -174,7 +175,7 @@ describe("Memory - update()", () => { const before: MemoryItem | null = await memory.get(id); const originalCreatedAt = before!.createdAt; - await memory.update(id, "New text"); + await memory.update(id, { text: "New text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.createdAt).toBe(originalCreatedAt); expect(after!.updatedAt).toBeDefined(); @@ -187,7 +188,7 @@ describe("Memory - update()", () => { }); const id = addResult.results[0].id; const before: MemoryItem | null = await memory.get(id); - await memory.update(id, "Completely different text"); + await memory.update(id, { text: "Completely different text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.hash).not.toBe(before!.hash); }); @@ -199,7 +200,7 @@ describe("Memory - update()", () => { infer: false, }); const id = addResult.results[0].id; - await memory.update(id, "Updated text"); + await memory.update(id, { text: "Updated text" }); const after: MemoryItem | null = await memory.get(id); expect(after!.memory).toBe("Updated text"); expect(after!.metadata).toEqual( @@ -208,6 +209,309 @@ describe("Memory - update()", () => { }); }); +// ─── update() options: text / data / metadata / expirationDate ─── + +describe("Memory - update() options", () => { + let memory: Memory; + let warnSpy: jest.SpyInstance; + const userId = `update_options_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + beforeEach(() => { + warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + async function seed(text: string): Promise { + const addResult: SearchResult = await memory.add(text, { + userId, + infer: false, + }); + return addResult.results[0].id; + } + + test("accepts an options object with text, without warning", async () => { + const id = await seed("Options before"); + await memory.update(id, { text: "Options after" }); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Options after"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test("accepts a bare text string, as on main", async () => { + const id = await seed("Bare before"); + await memory.update(id, "Bare after"); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Bare after"); + }); + + test("accepts the deprecated data alias and warns", async () => { + const id = await seed("Alias before"); + await memory.update(id, { data: "Alias after" }); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Alias after"); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("deprecated")); + }); + + // An empty `text` is content, so it must beat `data`. Guards against `||=`, + // which would treat "" as absent and store the `data` value instead. + test("text wins over data, even when text is empty", async () => { + const id = await seed("Both before"); + await memory.update(id, { text: "", data: "From data" }); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe(""); + }); + + // Same trap on the guard: "" must not read as "no field provided". + test("an empty bare string is content, not a missing argument", async () => { + const id = await seed("Empty bare"); + await memory.update(id, ""); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe(""); + }); + + test("updates metadata without touching the stored text", async () => { + const id = await seed("Metadata only"); + await memory.update(id, { metadata: { category: "solo" } }); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Metadata only"); + expect(after!.metadata).toEqual( + expect.objectContaining({ category: "solo" }), + ); + }); + + test("sets an expiration date without touching the stored text", async () => { + const id = await seed("Expiry only"); + await memory.update(id, { expirationDate: "2099-12-31" }); + const after: MemoryItem | null = await memory.get(id); + expect(after!.memory).toBe("Expiry only"); + expect(after!.metadata).toEqual( + expect.objectContaining({ expiration_date: "2099-12-31" }), + ); + }); + + // Python raises on `data=None` / `metadata=None`, so loose `== null` is the + // right check for both. `{}` is a real value and must not raise. + test.each([{}, { data: null }, { metadata: null }])( + "throws when %p provides nothing updatable", + async (options) => { + const id = await seed("Nothing to update"); + await expect(memory.update(id, options as any)).rejects.toThrow( + "At least one of text, metadata, or expirationDate must be provided.", + ); + expect(warnSpy).not.toHaveBeenCalled(); + }, + ); + + test("accepts empty metadata as an updatable field", async () => { + const id = await seed("Empty metadata"); + await expect(memory.update(id, { metadata: {} })).resolves.toEqual({ + message: "Memory updated successfully!", + }); + }); +}); + +// ─── expiration date parsing ───────────────────────────── + +describe("Memory - expiration date parsing", () => { + let memory: Memory; + const userId = `expiry_parse_test_${Date.now()}`; + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + // `new Date(...)` accepts all of these; Python's date.fromisoformat rejects + // them. The first two die to the format regex, the last two only to the + // UTC component round-trip: they match YYYY-MM-DD but are not real days. + const rejected = [ + "12/31/2099", // also shifts a day west of UTC + "2099-12-31T23:00:00", + "2099-02-30", // rolls over to 2099-03-02 + "2100-02-29", // 2100 is not a leap year + ]; + + test.each(rejected)("add() rejects %p", async (value) => { + await expect( + memory.add("Bad expiry", { userId, infer: false, expirationDate: value }), + ).rejects.toThrow("YYYY-MM-DD"); + }); + + // add() and update() share normalizeExpirationDate(); this checks the wiring. + test("update() rejects a malformed expiration date", async () => { + const addResult: SearchResult = await memory.add("Good", { + userId, + infer: false, + }); + await expect( + memory.update(addResult.results[0].id, { expirationDate: "12/31/2099" }), + ).rejects.toThrow("YYYY-MM-DD"); + }); + + // "2096-02-29" is a real leap day: it must survive the component check. + test.each(["2099-12-31", "2096-02-29"])( + "stores %p verbatim", + async (value) => { + const addResult: SearchResult = await memory.add("Good expiry", { + userId, + infer: false, + expirationDate: value, + }); + const item: MemoryItem | null = await memory.get(addResult.results[0].id); + expect(item!.metadata!.expiration_date).toBe(value); + }, + ); +}); + +// ─── expired memories are hidden on read ───────────────── + +describe("Memory - expired memories", () => { + let memory: Memory; + const userId = `expired_test_${Date.now()}`; + const today = new Date().toISOString().slice(0, 10); + + beforeAll(async () => { + memory = createMemory(); + }); + + afterAll(async () => { + await memory.reset(); + }); + + async function seed(text: string, expirationDate?: string): Promise { + const addResult: SearchResult = await memory.add(text, { + userId, + infer: false, + ...(expirationDate ? { expirationDate } : {}), + }); + return addResult.results[0].id; + } + + async function seedLiveAndDead(scopedUser: string): Promise { + await memory.add("Live memory", { userId: scopedUser, infer: false }); + await memory.add("Dead memory", { + userId: scopedUser, + infer: false, + expirationDate: "2020-01-01", + }); + } + + test("getAll() hides expired memories unless showExpired", async () => { + const scopedUser = `${userId}_getall`; + await seedLiveAndDead(scopedUser); + const filters = { user_id: scopedUser }; + + const hidden: SearchResult = await memory.getAll({ filters }); + expect(hidden.results.map((r) => r.memory)).toEqual(["Live memory"]); + + const shown: SearchResult = await memory.getAll({ + filters, + showExpired: true, + }); + expect(shown.results.map((r) => r.memory).sort()).toEqual([ + "Dead memory", + "Live memory", + ]); + }); + + test("search() hides expired memories unless showExpired", async () => { + const scopedUser = `${userId}_search`; + await seedLiveAndDead(scopedUser); + const filters = { user_id: scopedUser }; + + const hidden: SearchResult = await memory.search("memory", { filters }); + expect(hidden.results.map((r) => r.memory)).toEqual(["Live memory"]); + + const shown: SearchResult = await memory.search("memory", { + filters, + showExpired: true, + }); + expect(shown.results.map((r) => r.memory).sort()).toEqual([ + "Dead memory", + "Live memory", + ]); + }); + + test("a memory expiring today is not yet expired", async () => { + const scopedUser = `${userId}_today`; + await memory.add("Expires today", { + userId: scopedUser, + infer: false, + expirationDate: today, + }); + const result: SearchResult = await memory.getAll({ + filters: { user_id: scopedUser }, + }); + expect(result.results).toHaveLength(1); + }); + + test("get() still returns an expired memory by ID", async () => { + const id = await seed("Fetch by id", "2020-01-01"); + const item: MemoryItem | null = await memory.get(id); + expect(item).not.toBeNull(); + expect(item!.memory).toBe("Fetch by id"); + }); + + test("clearing the expiration date makes a memory visible again", async () => { + const scopedUser = `${userId}_revive`; + const addResult: SearchResult = await memory.add("Revived", { + userId: scopedUser, + infer: false, + expirationDate: "2020-01-01", + }); + const id = addResult.results[0].id; + + const before: SearchResult = await memory.getAll({ + filters: { user_id: scopedUser }, + }); + expect(before.results).toHaveLength(0); + + await memory.update(id, { expirationDate: null }); + + const after: SearchResult = await memory.getAll({ + filters: { user_id: scopedUser }, + }); + expect(after.results.map((r) => r.memory)).toEqual(["Revived"]); + }); + + test("getAll() still fills topK when expired memories are present", async () => { + const scopedUser = `${userId}_topk`; + for (let i = 0; i < 3; i++) { + await memory.add(`Dead ${i}`, { + userId: scopedUser, + infer: false, + expirationDate: "2020-01-01", + }); + } + for (let i = 0; i < 3; i++) { + await memory.add(`Live ${i}`, { userId: scopedUser, infer: false }); + } + + const result: SearchResult = await memory.getAll({ + filters: { user_id: scopedUser }, + topK: 3, + }); + expect(result.results).toHaveLength(3); + expect(result.results.every((r) => r.memory!.startsWith("Live"))).toBe( + true, + ); + }); +}); + // ─── delete() ──────────────────────────────────────────── describe("Memory - delete()", () => { @@ -428,7 +732,7 @@ describe("Memory - history()", () => { userId, }); const id = addResult.results[0].id; - await memory.update(id, "After"); + await memory.update(id, { text: "After" }); const history = await memory.history(id); expect(history.length).toBeGreaterThanOrEqual(2); }); diff --git a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts index 39a259856..b923760a7 100644 --- a/mem0-ts/src/oss/tests/vector-stores-compat.test.ts +++ b/mem0-ts/src/oss/tests/vector-stores-compat.test.ts @@ -2154,12 +2154,13 @@ describe("Memory class – backward compat with all providers", () => { }); it("all public methods work after initialization", async () => { + const memoryId = "3f0d5b6a-9c1e-4a2b-8d7f-1e2c3a4b5c6d"; const mockVStore = createMockVectorStore(); mockVStore.search.mockResolvedValue([ - { id: "id-1", payload: { memory: "test", hash: "h" }, score: 0.9 }, + { id: memoryId, payload: { memory: "test", hash: "h" }, score: 0.9 }, ]); mockVStore.get.mockResolvedValue({ - id: "id-1", + id: memoryId, payload: { memory: "test", hash: "h", @@ -2170,7 +2171,7 @@ describe("Memory class – backward compat with all providers", () => { mockVStore.list.mockResolvedValue([ [ { - id: "id-1", + id: memoryId, payload: { memory: "test", hash: "h", @@ -2204,15 +2205,15 @@ describe("Memory class – backward compat with all providers", () => { expect(searchResult).toBeDefined(); // get - const item = await mem.get("id-1"); + const item = await mem.get(memoryId); expect(item).toBeDefined(); // update - const updateResult = await mem.update("id-1", "new data"); + const updateResult = await mem.update(memoryId, { text: "new data" }); expect(updateResult.message).toBe("Memory updated successfully!"); // delete - const deleteResult = await mem.delete("id-1"); + const deleteResult = await mem.delete(memoryId); expect(deleteResult.message).toBe("Memory deleted successfully!"); // deleteAll @@ -2220,7 +2221,7 @@ describe("Memory class – backward compat with all providers", () => { expect(deleteAllResult.message).toBe("Memories deleted successfully!"); // history - const history = await mem.history("id-1"); + const history = await mem.history(memoryId); expect(Array.isArray(history)).toBe(true); }); diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 8cac28ca0..fa3829e3d 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -25,15 +25,12 @@ from mem0.configs.prompts import ( from mem0.exceptions import LLMError from mem0.exceptions import ValidationError as Mem0ValidationError from mem0.memory.base import MemoryBase -from mem0.memory.setup import mem0_dir, setup_config -from mem0.memory.storage import SQLiteManager -from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event from mem0.memory.notices import ( PERFORMANCE_SLOW_QUERY_THRESHOLD_SECONDS, - detect_scale_threshold_from_add_result, - detect_scale_threshold_from_top_k, detect_decay_usage_from_delete, detect_decay_usage_from_delete_all, + detect_scale_threshold_from_add_result, + detect_scale_threshold_from_top_k, detect_temporal_usage_from_metadata, detect_temporal_usage_from_search, display_decay_usage_notice, @@ -51,6 +48,9 @@ from mem0.memory.notices import ( get_temporal_feature_error_message, get_temporal_feature_error_message_async, ) +from mem0.memory.setup import mem0_dir, setup_config +from mem0.memory.storage import SQLiteManager +from mem0.memory.telemetry import MEM0_TELEMETRY, capture_event from mem0.memory.utils import ( extract_json, parse_messages, @@ -1767,30 +1767,41 @@ class Memory(MemoryBase): def update( self, memory_id, - data: Optional[str] = None, + text: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, expiration_date: Any = _UNSET, + data: Optional[str] = None, ): """ Update a memory by ID. Args: memory_id (str): ID of the memory to update. - data (str, optional): New content to update the memory with. + text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. + data (str, optional): Deprecated alias for ``text``. Will be removed in the next + major release; use ``text`` instead. Returns: dict: Success message indicating the memory was updated. Example: - >>> m.update(memory_id="mem_123", data="Likes to play tennis on weekends") + >>> m.update(memory_id="mem_123", text="Likes to play tennis on weekends") {'message': 'Memory updated successfully!'} """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "sync"}) - if data is None and metadata is None and expiration_date is _UNSET: - raise ValueError("At least one of data, metadata, or expiration_date must be provided.") + if data is not None: + logger.warning( + "The `data` argument to update() is deprecated and will be removed in the " + "next major release. Use `text` instead." + ) + if text is None: + text = data + + if text is None and metadata is None and expiration_date is _UNSET: + raise ValueError("At least one of text, metadata, or expiration_date must be provided.") update_metadata = deepcopy(metadata) if metadata is not None else None if expiration_date is not _UNSET: @@ -1798,10 +1809,10 @@ class Memory(MemoryBase): update_metadata["expiration_date"] = _normalize_expiration_date(expiration_date) existing_embeddings = {} - if data is not None: - existing_embeddings[data] = self.embedding_model.embed(data, "update") + if text is not None: + existing_embeddings[text] = self.embedding_model.embed(text, "update") - self._update_memory(memory_id, data, existing_embeddings, update_metadata) + self._update_memory(memory_id, text, existing_embeddings, update_metadata) display_first_run_notice(self, "sync", "update") return {"message": "Memory updated successfully!"} @@ -3387,30 +3398,41 @@ class AsyncMemory(MemoryBase): async def update( self, memory_id, - data: Optional[str] = None, + text: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, expiration_date: Any = _UNSET, + data: Optional[str] = None, ): """ Update a memory by ID asynchronously. Args: memory_id (str): ID of the memory to update. - data (str, optional): New content to update the memory with. + text (str, optional): New content to update the memory with. metadata (dict, optional): Metadata to update with the memory. Defaults to None. expiration_date (Any, optional): Date in YYYY-MM-DD format, or None to clear it. + data (str, optional): Deprecated alias for ``text``. Will be removed in the next + major release; use ``text`` instead. Returns: dict: Success message indicating the memory was updated. Example: - >>> await m.update(memory_id="mem_123", data="Likes to play tennis on weekends") + >>> await m.update(memory_id="mem_123", text="Likes to play tennis on weekends") {'message': 'Memory updated successfully!'} """ capture_event("mem0.update", self, {"memory_id": memory_id, "sync_type": "async"}) - if data is None and metadata is None and expiration_date is _UNSET: - raise ValueError("At least one of data, metadata, or expiration_date must be provided.") + if data is not None: + logger.warning( + "The `data` argument to update() is deprecated and will be removed in the " + "next major release. Use `text` instead." + ) + if text is None: + text = data + + if text is None and metadata is None and expiration_date is _UNSET: + raise ValueError("At least one of text, metadata, or expiration_date must be provided.") update_metadata = deepcopy(metadata) if metadata is not None else None if expiration_date is not _UNSET: @@ -3418,11 +3440,11 @@ class AsyncMemory(MemoryBase): update_metadata["expiration_date"] = _normalize_expiration_date(expiration_date) existing_embeddings = {} - if data is not None: - embeddings = await asyncio.to_thread(self.embedding_model.embed, data, "update") - existing_embeddings[data] = embeddings + if text is not None: + embeddings = await asyncio.to_thread(self.embedding_model.embed, text, "update") + existing_embeddings[text] = embeddings - await self._update_memory(memory_id, data, existing_embeddings, update_metadata) + await self._update_memory(memory_id, text, existing_embeddings, update_metadata) await display_first_run_notice_async(self, "async", "update") return {"message": "Memory updated successfully!"} diff --git a/skills/mem0-oss-to-platform/references/api-mapping.md b/skills/mem0-oss-to-platform/references/api-mapping.md index 9c9a04b66..3e16f7e8b 100644 --- a/skills/mem0-oss-to-platform/references/api-mapping.md +++ b/skills/mem0-oss-to-platform/references/api-mapping.md @@ -49,7 +49,7 @@ Notes: | get_all | `memory.get_all(user_id="u")` or `…, filters={"user_id":"u"}` | `memory.get_all(filters={"user_id": "u"}, page=1, page_size=N)` — entity IDs in `filters`; paginated with `page`/`page_size` (**not** `top_k`) | | delete_all | `memory.delete_all(user_id="u")` | `memory.delete_all(user_id="u")` — unchanged | | get | `memory.get(memory_id)` | `memory.get(memory_id)` | -| update | `memory.update(memory_id, data=...)` | `memory.update(memory_id, text=...)` — confirm param name against installed sig | +| update | `memory.update(memory_id, text=...)` *(`data=` is a deprecated alias)* | `memory.update(memory_id, text=...)` — unchanged | | delete | `memory.delete(memory_id)` | `memory.delete(memory_id)` | | reset | `memory.reset()` (wipes the local store) | **No global reset.** Use `memory.delete_all(filters=...)` scoped to the relevant entity. Flag this. | diff --git a/tests/memory/test_main.py b/tests/memory/test_main.py index 86acb8138..fd4d68852 100644 --- a/tests/memory/test_main.py +++ b/tests/memory/test_main.py @@ -190,6 +190,28 @@ class TestAsyncUpdate: "test_id", "Updated memory", {"Updated memory": [0.1, 0.2, 0.3]}, {} ) + @pytest.mark.asyncio + async def test_async_update_data_is_deprecated_alias_for_text(self, mock_async_memory, mocker, caplog): + mock_async_memory.embedding_model = Mock() + mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + mock_async_memory._update_memory = mocker.AsyncMock() + + # `data=` still works but emits a deprecation warning + with caplog.at_level(logging.WARNING): + await mock_async_memory.update("test_id", data="via data") + + assert any("deprecated" in record.message for record in caplog.records) + mock_async_memory._update_memory.assert_called_once_with( + "test_id", "via data", {"via data": [0.1, 0.2, 0.3]}, None + ) + + # `text` takes precedence when both are passed + mock_async_memory._update_memory.reset_mock() + await mock_async_memory.update("test_id", text="preferred", data="ignored") + mock_async_memory._update_memory.assert_called_once_with( + "test_id", "preferred", {"preferred": [0.1, 0.2, 0.3]}, None + ) + @pytest.mark.asyncio async def test_async_update_can_change_expiration_date_without_changing_text(self, mock_async_memory, mocker): mock_async_memory.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) diff --git a/tests/test_main.py b/tests/test_main.py index 4146c471d..6b0638355 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import logging import os from unittest.mock import Mock, patch @@ -212,6 +213,24 @@ def test_update_with_empty_metadata(memory_instance): ) +def test_update_data_is_deprecated_alias_for_text(memory_instance, caplog): + memory_instance.embedding_model = Mock() + memory_instance.embedding_model.embed = Mock(return_value=[0.1, 0.2, 0.3]) + memory_instance._update_memory = Mock() + + # `data=` still works but emits a deprecation warning + with caplog.at_level(logging.WARNING): + memory_instance.update("test_id", data="via data") + + assert any("deprecated" in record.message for record in caplog.records) + memory_instance._update_memory.assert_called_once_with("test_id", "via data", {"via data": [0.1, 0.2, 0.3]}, None) + + # `text` takes precedence when both are passed + memory_instance._update_memory.reset_mock() + memory_instance.update("test_id", text="preferred", data="ignored") + memory_instance._update_memory.assert_called_once_with("test_id", "preferred", {"preferred": [0.1, 0.2, 0.3]}, None) + + @pytest.mark.parametrize( ("expiration_date", "expected_expiration_date"), [