Review by @kartik-mem0. Root cause across all four: Mem0's hosted add with
infer:true is async and returns a PENDING event before the memory is visible.
- store.ts/tools.ts: add() returns AddResult{status,eventId} via parseAddResult;
the v3 add path returns {status:"PENDING", event_id}, so remember reports
{status:"queued", eventId} for async writes and {status:"saved"} only when
resolved. FAILED/unknown status maps to completed, not queued.
- messages.ts: drop typed non-text parts (reasoning no longer leaks into memory);
bound the captured assistant reply to the current turn and collect all of its
text segments (text->tool->text) instead of a stale prior answer.
- capture.ts + README + eve.mdx: document operationId dedup as best-effort
(lookup+add is not atomic; pending/concurrent windows can double-write);
removed the "restart replays cannot write twice" claim.
Tests: 68 pass (+15). Added reasoning, tool-only/cross-turn, multi-segment,
pending/concurrent capture replay, and PENDING/FAILED add-result cases.
Typecheck + build clean.
163 lines
4.2 KiB
TypeScript
163 lines
4.2 KiB
TypeScript
import type {
|
|
AddMemoryInput,
|
|
MemoryMessage,
|
|
MemoryRecord,
|
|
MemoryStore,
|
|
SearchHit,
|
|
SearchMemoryInput,
|
|
} from "../src/store.js";
|
|
|
|
export type FakeHit = SearchHit & {
|
|
readonly userId?: string;
|
|
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
};
|
|
|
|
export type FakeAdd = {
|
|
messages: readonly MemoryMessage[];
|
|
userId: string;
|
|
infer: boolean;
|
|
metadata: Readonly<Record<string, unknown>>;
|
|
};
|
|
|
|
export type FakeSearch = SearchMemoryInput & {
|
|
query: string;
|
|
};
|
|
|
|
export function createFakeStore(
|
|
hits: FakeHit[] = [],
|
|
options: { pendingWrites?: boolean } = {},
|
|
): MemoryStore & {
|
|
added: FakeAdd[];
|
|
searched: FakeSearch[];
|
|
deleted: string[];
|
|
records: MemoryRecord[];
|
|
} {
|
|
const added: FakeAdd[] = [];
|
|
const searched: FakeSearch[] = [];
|
|
const deleted: string[] = [];
|
|
const records: MemoryRecord[] = hits.map((hit) => ({
|
|
id: hit.id,
|
|
userId: hit.userId ?? "scope_abc",
|
|
metadata: hit.metadata ?? {},
|
|
}));
|
|
|
|
return {
|
|
added,
|
|
searched,
|
|
deleted,
|
|
records,
|
|
async add(messages, input) {
|
|
added.push({
|
|
messages,
|
|
userId: input.userId,
|
|
infer: input.infer,
|
|
metadata: input.metadata,
|
|
});
|
|
// Model Mem0's async extraction: a pending write is accepted but its
|
|
// memory is not yet visible to listByMetadata/get, so it cannot dedupe a
|
|
// replay. A resolved write becomes immediately visible.
|
|
if (options.pendingWrites) {
|
|
return { status: "queued" as const, eventId: `evt_${added.length}` };
|
|
}
|
|
records.push({
|
|
id: `mem_added_${added.length}`,
|
|
userId: input.userId,
|
|
metadata: input.metadata,
|
|
});
|
|
return { status: "completed" as const };
|
|
},
|
|
async search(query, input) {
|
|
searched.push({ query, ...input });
|
|
return {
|
|
results: hits.filter((hit) => (hit.userId ?? "scope_abc") === input.userId),
|
|
};
|
|
},
|
|
async get(memoryId) {
|
|
return records.find((record) => record.id === memoryId) ?? null;
|
|
},
|
|
async listByMetadata({ userId, metadata }) {
|
|
return records.filter((record) => {
|
|
if (record.userId !== userId) {
|
|
return false;
|
|
}
|
|
return Object.entries(metadata).every(
|
|
([key, value]) => record.metadata[key] === value,
|
|
);
|
|
});
|
|
},
|
|
async delete(memoryId, userId) {
|
|
const record = records.find((item) => item.id === memoryId);
|
|
if (!record || record.userId !== userId) {
|
|
throw new Error("Memory not found");
|
|
}
|
|
deleted.push(memoryId);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function memoryContext(input?: {
|
|
scopeKey?: string;
|
|
operationId?: string;
|
|
aborted?: boolean;
|
|
turn?:
|
|
| {
|
|
id?: string;
|
|
sequence?: number;
|
|
input: Array<{ role: string; content: unknown }>;
|
|
}
|
|
| null;
|
|
turnInput?: Array<{ role: string; content: unknown }>;
|
|
messages?: Array<{ role: string; content: unknown }>;
|
|
}): {
|
|
abortSignal: AbortSignal;
|
|
operationId: string;
|
|
session: { id: string };
|
|
memory: {
|
|
scope: { key: string; namespace: string; value: string };
|
|
slot: string;
|
|
};
|
|
messages: Array<{ role: string; content: unknown }>;
|
|
turn: {
|
|
id: string;
|
|
sequence: number;
|
|
input: Array<{ role: string; content: unknown }>;
|
|
} | null;
|
|
} {
|
|
const controller = new AbortController();
|
|
if (input?.aborted) {
|
|
controller.abort();
|
|
}
|
|
|
|
const turnInput =
|
|
input?.turn === null
|
|
? undefined
|
|
: (input?.turn?.input ??
|
|
input?.turnInput ?? [{ role: "user", content: "I am vegetarian" }]);
|
|
|
|
return {
|
|
abortSignal: controller.signal,
|
|
operationId: input?.operationId ?? "op_1",
|
|
session: { id: "sess_1" },
|
|
memory: {
|
|
scope: {
|
|
key: input?.scopeKey ?? "scope_abc",
|
|
namespace: "default",
|
|
value: "user_1",
|
|
},
|
|
slot: "mem0",
|
|
},
|
|
messages: input?.messages ?? [
|
|
{ role: "user", content: "I am vegetarian" },
|
|
{ role: "assistant", content: "I will remember that." },
|
|
],
|
|
turn:
|
|
input?.turn === null
|
|
? null
|
|
: {
|
|
id: input?.turn?.id ?? "turn_1",
|
|
sequence: input?.turn?.sequence ?? 1,
|
|
input: turnInput ?? [{ role: "user", content: "I am vegetarian" }],
|
|
},
|
|
};
|
|
}
|