feat(oss): accept text in Memory.update(), deprecate data (#6044)
This commit is contained in:
@@ -110,14 +110,42 @@ You only need to provide API keys - all other settings are optional.
|
||||
### Methods
|
||||
|
||||
- `add(messages: string | Message[], userId?: string, ...): Promise<SearchResult>`
|
||||
- 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<SearchResult>`
|
||||
- Expired memories are omitted unless you pass `showExpired: true`.
|
||||
- `get(memoryId: string): Promise<MemoryItem | null>`
|
||||
- `update(memoryId: string, data: string): Promise<{ message: string }>`
|
||||
- Fetching by ID returns the memory even if it has expired.
|
||||
- `getAll(options): Promise<SearchResult>`
|
||||
- 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<any[]>`
|
||||
- `reset(): Promise<void>`
|
||||
|
||||
```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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, any> = { ...metadata };
|
||||
if (expirationDate !== undefined) {
|
||||
updateMetadata.expiration_date =
|
||||
expirationDate === null
|
||||
? null
|
||||
: normalizeExpirationDate(expirationDate);
|
||||
}
|
||||
|
||||
const existingEmbeddings: Record<string, number[]> = {};
|
||||
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<string, number[]>,
|
||||
metadata: Record<string, any> = {},
|
||||
): Promise<string> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, any>;
|
||||
/** 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 {}
|
||||
|
||||
@@ -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<string, any> | 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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
/// <reference types="jest" />
|
||||
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<string> {
|
||||
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<string> {
|
||||
const addResult: SearchResult = await memory.add(text, {
|
||||
userId,
|
||||
infer: false,
|
||||
...(expirationDate ? { expirationDate } : {}),
|
||||
});
|
||||
return addResult.results[0].id;
|
||||
}
|
||||
|
||||
async function seedLiveAndDead(scopedUser: string): Promise<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user