Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef3f40825 |
@@ -216,7 +216,7 @@ memory.delete_all(user_id="alice")
|
|||||||
## Put it into practice
|
## Put it into practice
|
||||||
|
|
||||||
- Review the <Link href="/api-reference/memory/delete-memory">Delete Memory API reference</Link>, plus <Link href="/api-reference/memory/batch-delete">Batch Delete</Link> and <Link href="/api-reference/memory/delete-memories">Filtered Delete</Link>.
|
- Review the <Link href="/api-reference/memory/delete-memory">Delete Memory API reference</Link>, plus <Link href="/api-reference/memory/batch-delete">Batch Delete</Link> and <Link href="/api-reference/memory/delete-memories">Filtered Delete</Link>.
|
||||||
- Pair deletes with <Link href="/platform/features/expiration-date">Expiration Policies</Link> to automate retention.
|
- Pair deletes with <Link href="/cookbooks/essentials/memory-expiration-short-and-long-term">Expiration Policies</Link> to automate retention.
|
||||||
|
|
||||||
## See it live
|
## See it live
|
||||||
|
|
||||||
@@ -236,6 +236,6 @@ memory.delete_all(user_id="alice")
|
|||||||
title="Enable Expiration Policies"
|
title="Enable Expiration Policies"
|
||||||
description="Automate retention with the platform’s expiration feature."
|
description="Automate retention with the platform’s expiration feature."
|
||||||
icon="clock"
|
icon="clock"
|
||||||
href="/platform/features/expiration-date"
|
href="/cookbooks/essentials/memory-expiration-short-and-long-term"
|
||||||
/>
|
/>
|
||||||
</CardGroup>
|
</CardGroup>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0ai",
|
"name": "mem0ai",
|
||||||
"version": "3.0.0-beta.2",
|
"version": "3.0.0-beta.1",
|
||||||
"description": "The Memory Layer For Your AI Apps",
|
"description": "The Memory Layer For Your AI Apps",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|||||||
@@ -262,181 +262,6 @@ export class Memory {
|
|||||||
return this._entityStore;
|
return this._entityStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a filters object for entity-store scoping: keeps only
|
|
||||||
* user_id/agent_id/run_id keys whose values are defined.
|
|
||||||
*/
|
|
||||||
private _sessionFiltersFromPayload(
|
|
||||||
payload: Record<string, any>,
|
|
||||||
): Record<string, any> {
|
|
||||||
const filters: Record<string, any> = {};
|
|
||||||
if (payload.user_id) filters.user_id = payload.user_id;
|
|
||||||
if (payload.agent_id) filters.agent_id = payload.agent_id;
|
|
||||||
if (payload.run_id) filters.run_id = payload.run_id;
|
|
||||||
return filters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove `memoryId` from every entity record scoped to `filters`.
|
|
||||||
* If an entity's `linkedMemoryIds` becomes empty after removal, the
|
|
||||||
* entity record itself is deleted. Errors on individual entities are
|
|
||||||
* swallowed so one bad record does not break the whole operation.
|
|
||||||
*
|
|
||||||
* No-op if the entity store has not been initialized yet.
|
|
||||||
*/
|
|
||||||
private async _removeMemoryFromEntityStore(
|
|
||||||
memoryId: string,
|
|
||||||
filters: Record<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
let entityStore: VectorStore;
|
|
||||||
try {
|
|
||||||
entityStore = await this.getEntityStore();
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity store unavailable during cleanup: ${e}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Array<{ id: string; payload: Record<string, any> }> = [];
|
|
||||||
try {
|
|
||||||
const listed = await entityStore.list(filters, 10000);
|
|
||||||
rows = (
|
|
||||||
Array.isArray(listed) && Array.isArray(listed[0])
|
|
||||||
? listed[0]
|
|
||||||
: (listed as any)
|
|
||||||
) as Array<{ id: string; payload: Record<string, any> }>;
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity store list failed during cleanup: ${e}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
try {
|
|
||||||
const payload = row.payload || {};
|
|
||||||
const linked: string[] = Array.isArray(payload.linkedMemoryIds)
|
|
||||||
? payload.linkedMemoryIds
|
|
||||||
: [];
|
|
||||||
if (!linked.includes(memoryId)) continue;
|
|
||||||
|
|
||||||
const remaining = linked.filter((id) => id !== memoryId);
|
|
||||||
if (remaining.length === 0) {
|
|
||||||
try {
|
|
||||||
await entityStore.delete(row.id);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity delete failed for id=${row.id}: ${e}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newPayload = { ...payload, linkedMemoryIds: remaining };
|
|
||||||
// entityStore.update requires a vector — re-embed entity text.
|
|
||||||
const entityText =
|
|
||||||
typeof payload.data === "string" ? payload.data : "";
|
|
||||||
if (!entityText) {
|
|
||||||
// Can't re-embed without text; skip gracefully.
|
|
||||||
console.debug(
|
|
||||||
`Entity id=${row.id} missing 'data'; skipping update during cleanup`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let vec: number[];
|
|
||||||
try {
|
|
||||||
vec = await this.embedder.embed(entityText);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity re-embed failed for '${entityText}': ${e}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await entityStore.update(row.id, vec, newPayload);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity update failed for id=${row.id}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity cleanup error for id=${row?.id}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract entities from `text` and link them to `memoryId` in the
|
|
||||||
* entity store, scoped to `filters` (user_id / agent_id / run_id).
|
|
||||||
*
|
|
||||||
* Simpler single-memory variant of Phase 7 in add(): no cross-memory
|
|
||||||
* dedup, but still does per-entity "search for existing, update if
|
|
||||||
* match >= 0.95 else insert new". Non-fatal errors are swallowed.
|
|
||||||
*/
|
|
||||||
private async _linkEntitiesForMemory(
|
|
||||||
memoryId: string,
|
|
||||||
text: string,
|
|
||||||
filters: Record<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const entities = extractEntities(text);
|
|
||||||
if (entities.length === 0) return;
|
|
||||||
|
|
||||||
const entityStore = await this.getEntityStore();
|
|
||||||
|
|
||||||
for (const entity of entities) {
|
|
||||||
try {
|
|
||||||
let entityVec: number[];
|
|
||||||
try {
|
|
||||||
entityVec = await this.embedder.embed(entity.text);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity embed failed for '${entity.text}': ${e}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let matches: Array<{
|
|
||||||
id: string;
|
|
||||||
score?: number;
|
|
||||||
payload: Record<string, any>;
|
|
||||||
}> = [];
|
|
||||||
try {
|
|
||||||
matches = await entityStore.search(entityVec, 1, filters);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (matches.length > 0 && (matches[0].score ?? 0) >= 0.95) {
|
|
||||||
const match = matches[0];
|
|
||||||
const payload = match.payload || {};
|
|
||||||
const linked = new Set<string>(
|
|
||||||
Array.isArray(payload.linkedMemoryIds)
|
|
||||||
? payload.linkedMemoryIds
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
linked.add(memoryId);
|
|
||||||
payload.linkedMemoryIds = Array.from(linked).sort();
|
|
||||||
try {
|
|
||||||
await entityStore.update(match.id, entityVec, payload);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity update failed for '${entity.text}': ${e}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const entityPayload: Record<string, any> = {
|
|
||||||
data: entity.text,
|
|
||||||
entityType: entity.type,
|
|
||||||
linkedMemoryIds: [memoryId],
|
|
||||||
};
|
|
||||||
if (filters.user_id) entityPayload.user_id = filters.user_id;
|
|
||||||
if (filters.agent_id) entityPayload.agent_id = filters.agent_id;
|
|
||||||
if (filters.run_id) entityPayload.run_id = filters.run_id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await entityStore.insert(
|
|
||||||
[entityVec],
|
|
||||||
[uuidv4()],
|
|
||||||
[entityPayload],
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity insert failed for '${entity.text}': ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug(`Entity link error for '${entity.text}': ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Entity linking failed during update: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildSessionScope(filters: SearchFilters): string {
|
private buildSessionScope(filters: SearchFilters): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
|
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
|
||||||
@@ -1038,23 +863,17 @@ export class Memory {
|
|||||||
// Validate search parameters (before applying defaults)
|
// Validate search parameters (before applying defaults)
|
||||||
validateSearchParams(config.threshold, config.topK);
|
validateSearchParams(config.threshold, config.topK);
|
||||||
|
|
||||||
// Validate and trim entity IDs in filters. Only include keys whose
|
// Validate and trim entity IDs in filters
|
||||||
// validated value is defined — otherwise downstream vector stores
|
const normalizedFilters = config.filters
|
||||||
// receive `agent_id: undefined` / `run_id: undefined` and fail
|
? {
|
||||||
// (Qdrant rejects the malformed match, pgvector binds NULL, Redis
|
...config.filters,
|
||||||
// emits a literal "undefined" string in TAG filters).
|
user_id: validateAndTrimEntityId(config.filters.user_id, "user_id"),
|
||||||
const normalizedFilters: Record<string, any> = config.filters
|
agent_id: validateAndTrimEntityId(
|
||||||
? Object.fromEntries(
|
config.filters.agent_id,
|
||||||
Object.entries({
|
"agent_id",
|
||||||
...config.filters,
|
),
|
||||||
user_id: validateAndTrimEntityId(config.filters.user_id, "user_id"),
|
run_id: validateAndTrimEntityId(config.filters.run_id, "run_id"),
|
||||||
agent_id: validateAndTrimEntityId(
|
}
|
||||||
config.filters.agent_id,
|
|
||||||
"agent_id",
|
|
||||||
),
|
|
||||||
run_id: validateAndTrimEntityId(config.filters.run_id, "run_id"),
|
|
||||||
}).filter(([, v]) => v !== undefined),
|
|
||||||
)
|
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
await this._ensureInitialized();
|
await this._ensureInitialized();
|
||||||
@@ -1375,17 +1194,13 @@ export class Memory {
|
|||||||
|
|
||||||
const { topK = 20 } = config;
|
const { topK = 20 } = config;
|
||||||
|
|
||||||
// Validate and trim entity IDs in filters. Drop keys that resolve to
|
// Validate and trim entity IDs in filters
|
||||||
// undefined so downstream vector stores don't receive
|
const filters = {
|
||||||
// `agent_id: undefined` / `run_id: undefined` and fail.
|
...(config.filters || {}),
|
||||||
const filters: Record<string, any> = Object.fromEntries(
|
user_id: validateAndTrimEntityId(config.filters?.user_id, "user_id"),
|
||||||
Object.entries({
|
agent_id: validateAndTrimEntityId(config.filters?.agent_id, "agent_id"),
|
||||||
...(config.filters || {}),
|
run_id: validateAndTrimEntityId(config.filters?.run_id, "run_id"),
|
||||||
user_id: validateAndTrimEntityId(config.filters?.user_id, "user_id"),
|
};
|
||||||
agent_id: validateAndTrimEntityId(config.filters?.agent_id, "agent_id"),
|
|
||||||
run_id: validateAndTrimEntityId(config.filters?.run_id, "run_id"),
|
|
||||||
}).filter(([, v]) => v !== undefined),
|
|
||||||
);
|
|
||||||
|
|
||||||
await this._captureEvent("get_all", {
|
await this._captureEvent("get_all", {
|
||||||
topK,
|
topK,
|
||||||
@@ -1445,7 +1260,6 @@ export class Memory {
|
|||||||
...metadata,
|
...metadata,
|
||||||
data,
|
data,
|
||||||
hash: createHash("md5").update(data).digest("hex"),
|
hash: createHash("md5").update(data).digest("hex"),
|
||||||
textLemmatized: lemmatizeForBm25(data),
|
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1503,16 +1317,6 @@ export class Memory {
|
|||||||
newMetadata.updatedAt,
|
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return memoryId;
|
return memoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1523,9 +1327,6 @@ export class Memory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prevValue = existingMemory.payload.data;
|
const prevValue = existingMemory.payload.data;
|
||||||
const sessionFilters = this._sessionFiltersFromPayload(
|
|
||||||
existingMemory.payload || {},
|
|
||||||
);
|
|
||||||
await this.vectorStore.delete(memoryId);
|
await this.vectorStore.delete(memoryId);
|
||||||
await this.db.addHistory(
|
await this.db.addHistory(
|
||||||
memoryId,
|
memoryId,
|
||||||
@@ -1537,14 +1338,6 @@ export class Memory {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Entity-store cleanup: strip this memory's id from any entity records
|
|
||||||
// that linked to it. Non-fatal — log and continue on error.
|
|
||||||
try {
|
|
||||||
await this._removeMemoryFromEntityStore(memoryId, sessionFilters);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Entity store cleanup failed during delete: ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return memoryId;
|
return memoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -660,9 +660,6 @@ export function extractEntities(text: string): ExtractedEntity[] {
|
|||||||
txt = txt.replace(/\s*:+$/, "");
|
txt = txt.replace(/\s*:+$/, "");
|
||||||
// Strip leading numbered list markers
|
// Strip leading numbered list markers
|
||||||
txt = txt.replace(/^\d+\s*\.\s*/, "");
|
txt = txt.replace(/^\d+\s*\.\s*/, "");
|
||||||
// Strip trailing sentence punctuation (".", ",", ";", "!", "?") — otherwise
|
|
||||||
// "Paris." and "Paris" produce different embeddings and break entity dedup.
|
|
||||||
txt = txt.replace(/[.,;!?]+$/, "").trim();
|
|
||||||
|
|
||||||
if (!txt || txt.length <= 2 || hasArtifacts(txt)) {
|
if (!txt || txt.length <= 2 || hasArtifacts(txt)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -9,19 +9,6 @@ import type {
|
|||||||
import { VectorStore } from "./base";
|
import { VectorStore } from "./base";
|
||||||
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
import { SearchFilters, VectorStoreConfig, VectorStoreResult } from "../types";
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape RediSearch TAG filter special characters. Any punctuation in the
|
|
||||||
* value (including `-`, which appears in every UUID) must be backslash-
|
|
||||||
* escaped, otherwise RediSearch either parses it as an operator (`-` is
|
|
||||||
* minus, `|` is OR) or rejects the whole expression as a syntax error.
|
|
||||||
*/
|
|
||||||
function escapeRedisTagValue(value: unknown): string {
|
|
||||||
return String(value).replace(
|
|
||||||
/([,.<>{}\[\]"':;!@#$%^&*()\-+=~|/\\\s])/g,
|
|
||||||
"\\$1",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RedisConfig extends VectorStoreConfig {
|
interface RedisConfig extends VectorStoreConfig {
|
||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
collectionName: string;
|
collectionName: string;
|
||||||
@@ -390,8 +377,8 @@ export class RedisDB implements VectorStore {
|
|||||||
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
||||||
const filterExpr = snakeFilters
|
const filterExpr = snakeFilters
|
||||||
? Object.entries(snakeFilters)
|
? Object.entries(snakeFilters)
|
||||||
.filter(([_, value]) => value !== null && value !== undefined)
|
.filter(([_, value]) => value !== null)
|
||||||
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
|
.map(([key, value]) => `@${key}:{${value}}`)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
: "*";
|
: "*";
|
||||||
|
|
||||||
@@ -628,8 +615,8 @@ export class RedisDB implements VectorStore {
|
|||||||
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
const snakeFilters = filters ? toSnakeCase(filters) : undefined;
|
||||||
const filterExpr = snakeFilters
|
const filterExpr = snakeFilters
|
||||||
? Object.entries(snakeFilters)
|
? Object.entries(snakeFilters)
|
||||||
.filter(([_, value]) => value !== null && value !== undefined)
|
.filter(([_, value]) => value !== null)
|
||||||
.map(([key, value]) => `@${key}:{${escapeRedisTagValue(value)}}`)
|
.map(([key, value]) => `@${key}:{${value}}`)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
: "*";
|
: "*";
|
||||||
|
|
||||||
|
|||||||
@@ -453,84 +453,6 @@ class Memory(MemoryBase):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Entity upsert failed for '{entity_text}': {e}")
|
logger.warning(f"Entity upsert failed for '{entity_text}': {e}")
|
||||||
|
|
||||||
def _remove_memory_from_entity_store(self, memory_id, filters):
|
|
||||||
"""Strip `memory_id` from every entity record scoped to `filters`.
|
|
||||||
|
|
||||||
For each entity whose `linked_memory_ids` contains `memory_id`:
|
|
||||||
- remove the id; if the list becomes empty, delete the entity record.
|
|
||||||
- otherwise re-embed the entity text and update the payload
|
|
||||||
(the vector store's update() requires a vector).
|
|
||||||
|
|
||||||
No-op if the entity store has never been initialized in this process.
|
|
||||||
Errors on individual entities are swallowed at debug level; outer
|
|
||||||
failures are swallowed at warning level so the primary delete/update
|
|
||||||
path is never broken by entity cleanup.
|
|
||||||
"""
|
|
||||||
if self._entity_store is None:
|
|
||||||
return
|
|
||||||
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
|
|
||||||
try:
|
|
||||||
listed = self.entity_store.list(filters=search_filters, top_k=10000)
|
|
||||||
rows = listed[0] if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list) else listed
|
|
||||||
for row in rows or []:
|
|
||||||
try:
|
|
||||||
payload = getattr(row, "payload", None) or {}
|
|
||||||
linked = payload.get("linked_memory_ids", [])
|
|
||||||
if not isinstance(linked, list) or memory_id not in linked:
|
|
||||||
continue
|
|
||||||
remaining = [mid for mid in linked if mid != memory_id]
|
|
||||||
if not remaining:
|
|
||||||
try:
|
|
||||||
self.entity_store.delete(vector_id=row.id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity delete failed for id={row.id}: {e}")
|
|
||||||
else:
|
|
||||||
entity_text = payload.get("data")
|
|
||||||
if not isinstance(entity_text, str) or not entity_text:
|
|
||||||
logger.debug(f"Entity id={row.id} missing 'data'; skipping update during cleanup")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
vec = self.embedding_model.embed(entity_text, "update")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity re-embed failed for '{entity_text}': {e}")
|
|
||||||
continue
|
|
||||||
new_payload = {**payload, "linked_memory_ids": remaining}
|
|
||||||
try:
|
|
||||||
self.entity_store.update(
|
|
||||||
vector_id=row.id,
|
|
||||||
vector=vec,
|
|
||||||
payload=new_payload,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity update failed for id={row.id}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity cleanup error: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity store cleanup failed for memory_id={memory_id}: {e}")
|
|
||||||
|
|
||||||
def _link_entities_for_memory(self, memory_id, text, filters):
|
|
||||||
"""Extract entities from `text` and link them to `memory_id` in the
|
|
||||||
entity store, scoped to `filters`. Simpler single-memory variant of
|
|
||||||
Phase 7 in add(): per-entity search-then-update-or-insert via the
|
|
||||||
existing `_upsert_entity` helper. Non-fatal on any failure.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
entities = extract_entities(text)
|
|
||||||
if not entities:
|
|
||||||
return
|
|
||||||
seen = set()
|
|
||||||
for entity_type, entity_text in entities:
|
|
||||||
key = entity_text.strip().lower()
|
|
||||||
if not key or key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
try:
|
|
||||||
self._upsert_entity(entity_text, entity_type, memory_id, filters)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity link failed for '{entity_text}': {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity linking failed for memory_id={memory_id}: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config_dict: Dict[str, Any]):
|
def from_config(cls, config_dict: Dict[str, Any]):
|
||||||
try:
|
try:
|
||||||
@@ -1702,13 +1624,6 @@ class Memory(MemoryBase):
|
|||||||
actor_id=new_metadata.get("actor_id"),
|
actor_id=new_metadata.get("actor_id"),
|
||||||
role=new_metadata.get("role"),
|
role=new_metadata.get("role"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entity-store cleanup: strip this memory's id from old-text entities,
|
|
||||||
# then re-extract entities from the new text and link them back.
|
|
||||||
session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)}
|
|
||||||
self._remove_memory_from_entity_store(memory_id, session_filters)
|
|
||||||
self._link_entities_for_memory(memory_id, data, session_filters)
|
|
||||||
|
|
||||||
return memory_id
|
return memory_id
|
||||||
|
|
||||||
def _delete_memory(self, memory_id, existing_memory=None):
|
def _delete_memory(self, memory_id, existing_memory=None):
|
||||||
@@ -1720,8 +1635,6 @@ class Memory(MemoryBase):
|
|||||||
prev_value = existing_memory.payload.get("data", "")
|
prev_value = existing_memory.payload.get("data", "")
|
||||||
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
||||||
updated_at = datetime.now(timezone.utc).isoformat()
|
updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
payload = existing_memory.payload or {}
|
|
||||||
session_filters = {k: payload[k] for k in ("user_id", "agent_id", "run_id") if payload.get(k)}
|
|
||||||
self.vector_store.delete(vector_id=memory_id)
|
self.vector_store.delete(vector_id=memory_id)
|
||||||
self.db.add_history(
|
self.db.add_history(
|
||||||
memory_id,
|
memory_id,
|
||||||
@@ -1734,11 +1647,6 @@ class Memory(MemoryBase):
|
|||||||
role=existing_memory.payload.get("role"),
|
role=existing_memory.payload.get("role"),
|
||||||
is_deleted=1,
|
is_deleted=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entity-store cleanup: strip this memory's id from any entity records
|
|
||||||
# that linked to it. Non-fatal — the helper swallows errors.
|
|
||||||
self._remove_memory_from_entity_store(memory_id, session_filters)
|
|
||||||
|
|
||||||
return memory_id
|
return memory_id
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
@@ -1845,114 +1753,6 @@ class AsyncMemory(MemoryBase):
|
|||||||
)
|
)
|
||||||
return self._entity_store
|
return self._entity_store
|
||||||
|
|
||||||
async def _upsert_entity_async(self, entity_text, entity_type, memory_id, filters):
|
|
||||||
"""Async variant of `_upsert_entity` — per-entity search-then-update-or-insert."""
|
|
||||||
try:
|
|
||||||
entity_embedding = await asyncio.to_thread(self.embedding_model.embed, entity_text, "add")
|
|
||||||
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
|
|
||||||
|
|
||||||
existing = await asyncio.to_thread(
|
|
||||||
self.entity_store.search,
|
|
||||||
query=entity_text,
|
|
||||||
vectors=entity_embedding,
|
|
||||||
top_k=1,
|
|
||||||
filters=search_filters,
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing and existing[0].score >= 0.95:
|
|
||||||
match = existing[0]
|
|
||||||
payload = match.payload or {}
|
|
||||||
linked_ids = payload.get("linked_memory_ids", [])
|
|
||||||
if memory_id not in linked_ids:
|
|
||||||
linked_ids.append(memory_id)
|
|
||||||
payload["linked_memory_ids"] = linked_ids
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.entity_store.update,
|
|
||||||
vector_id=match.id,
|
|
||||||
vector=None,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
entity_id = str(uuid.uuid4())
|
|
||||||
entity_payload = {
|
|
||||||
"data": entity_text,
|
|
||||||
"entity_type": entity_type,
|
|
||||||
"linked_memory_ids": [memory_id],
|
|
||||||
**{k: v for k, v in search_filters.items()},
|
|
||||||
}
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.entity_store.insert,
|
|
||||||
vectors=[entity_embedding],
|
|
||||||
ids=[entity_id],
|
|
||||||
payloads=[entity_payload],
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity upsert failed for '{entity_text}' (async): {e}")
|
|
||||||
|
|
||||||
async def _remove_memory_from_entity_store(self, memory_id, filters):
|
|
||||||
"""Async variant of `Memory._remove_memory_from_entity_store`."""
|
|
||||||
if self._entity_store is None:
|
|
||||||
return
|
|
||||||
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
|
|
||||||
try:
|
|
||||||
listed = await asyncio.to_thread(self.entity_store.list, filters=search_filters, top_k=10000)
|
|
||||||
rows = listed[0] if isinstance(listed, (list, tuple)) and listed and isinstance(listed[0], list) else listed
|
|
||||||
for row in rows or []:
|
|
||||||
try:
|
|
||||||
payload = getattr(row, "payload", None) or {}
|
|
||||||
linked = payload.get("linked_memory_ids", [])
|
|
||||||
if not isinstance(linked, list) or memory_id not in linked:
|
|
||||||
continue
|
|
||||||
remaining = [mid for mid in linked if mid != memory_id]
|
|
||||||
if not remaining:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(self.entity_store.delete, vector_id=row.id)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity delete failed for id={row.id} (async): {e}")
|
|
||||||
else:
|
|
||||||
entity_text = payload.get("data")
|
|
||||||
if not isinstance(entity_text, str) or not entity_text:
|
|
||||||
logger.debug(f"Entity id={row.id} missing 'data'; skipping update during cleanup (async)")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
vec = await asyncio.to_thread(self.embedding_model.embed, entity_text, "update")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity re-embed failed for '{entity_text}' (async): {e}")
|
|
||||||
continue
|
|
||||||
new_payload = {**payload, "linked_memory_ids": remaining}
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
self.entity_store.update,
|
|
||||||
vector_id=row.id,
|
|
||||||
vector=vec,
|
|
||||||
payload=new_payload,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity update failed for id={row.id} (async): {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity cleanup error (async): {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity store cleanup failed for memory_id={memory_id} (async): {e}")
|
|
||||||
|
|
||||||
async def _link_entities_for_memory(self, memory_id, text, filters):
|
|
||||||
"""Async variant of `Memory._link_entities_for_memory`."""
|
|
||||||
try:
|
|
||||||
entities = await asyncio.to_thread(extract_entities, text)
|
|
||||||
if not entities:
|
|
||||||
return
|
|
||||||
seen = set()
|
|
||||||
for entity_type, entity_text in entities:
|
|
||||||
key = entity_text.strip().lower()
|
|
||||||
if not key or key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
try:
|
|
||||||
await self._upsert_entity_async(entity_text, entity_type, memory_id, filters)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Entity link failed for '{entity_text}' (async): {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Entity linking failed for memory_id={memory_id} (async): {e}")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config_dict: Dict[str, Any]):
|
def from_config(cls, config_dict: Dict[str, Any]):
|
||||||
try:
|
try:
|
||||||
@@ -3126,13 +2926,6 @@ class AsyncMemory(MemoryBase):
|
|||||||
actor_id=new_metadata.get("actor_id"),
|
actor_id=new_metadata.get("actor_id"),
|
||||||
role=new_metadata.get("role"),
|
role=new_metadata.get("role"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entity-store cleanup: strip this memory's id from old-text entities,
|
|
||||||
# then re-extract entities from the new text and link them back.
|
|
||||||
session_filters = {k: new_metadata[k] for k in ("user_id", "agent_id", "run_id") if new_metadata.get(k)}
|
|
||||||
await self._remove_memory_from_entity_store(memory_id, session_filters)
|
|
||||||
await self._link_entities_for_memory(memory_id, data, session_filters)
|
|
||||||
|
|
||||||
return memory_id
|
return memory_id
|
||||||
|
|
||||||
async def _delete_memory(self, memory_id, existing_memory=None):
|
async def _delete_memory(self, memory_id, existing_memory=None):
|
||||||
@@ -3144,8 +2937,6 @@ class AsyncMemory(MemoryBase):
|
|||||||
prev_value = existing_memory.payload.get("data", "")
|
prev_value = existing_memory.payload.get("data", "")
|
||||||
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
created_at = _normalize_iso_timestamp_to_utc(existing_memory.payload.get("created_at"))
|
||||||
updated_at = datetime.now(timezone.utc).isoformat()
|
updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
payload = existing_memory.payload or {}
|
|
||||||
session_filters = {k: payload[k] for k in ("user_id", "agent_id", "run_id") if payload.get(k)}
|
|
||||||
|
|
||||||
await asyncio.to_thread(self.vector_store.delete, vector_id=memory_id)
|
await asyncio.to_thread(self.vector_store.delete, vector_id=memory_id)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -3161,10 +2952,6 @@ class AsyncMemory(MemoryBase):
|
|||||||
is_deleted=1,
|
is_deleted=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Entity-store cleanup: strip this memory's id from any entity records
|
|
||||||
# that linked to it. Non-fatal — the helper swallows errors.
|
|
||||||
await self._remove_memory_from_entity_store(memory_id, session_filters)
|
|
||||||
|
|
||||||
return memory_id
|
return memory_id
|
||||||
|
|
||||||
async def reset(self):
|
async def reset(self):
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0ai"
|
name = "mem0ai"
|
||||||
version = "2.0.0b2"
|
version = "2.0.0b1"
|
||||||
description = "Long-term memory for AI Agents"
|
description = "Long-term memory for AI Agents"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Mem0", email = "support@mem0.ai" }
|
{ name = "Mem0", email = "support@mem0.ai" }
|
||||||
|
|||||||
Reference in New Issue
Block a user