feat(cli): Platform option parity across Python and Node CLIs (MEM-5893) (#6696)

This commit is contained in:
Kartik
2026-08-03 17:10:44 +05:30
committed by GitHub
parent 50bdaaea0c
commit c90bdbdce0
29 changed files with 2030 additions and 541 deletions
+16 -1
View File
@@ -14,7 +14,10 @@ export interface AddOptions {
immutable?: boolean;
infer?: boolean;
expires?: string;
categories?: string[];
customInstructions?: string;
customCategories?: Record<string, string>[];
structuredDataSchema?: Record<string, unknown>;
timestamp?: number;
}
export interface SearchOptions {
@@ -28,6 +31,9 @@ export interface SearchOptions {
keyword?: boolean;
filters?: Record<string, unknown>;
fields?: string[];
showExpired?: boolean;
referenceDate?: string | number;
latestOnly?: boolean;
}
export interface ListOptions {
@@ -40,6 +46,8 @@ export interface ListOptions {
category?: string;
after?: string;
before?: string;
showExpired?: boolean;
latestOnly?: boolean;
}
export interface DeleteOptions {
@@ -48,6 +56,12 @@ export interface DeleteOptions {
agentId?: string;
appId?: string;
runId?: string;
deleteLinked?: boolean;
}
export interface UpdateOptions {
expirationDate?: string;
timestamp?: number;
}
export interface EntityIds {
@@ -77,6 +91,7 @@ export interface Backend {
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
opts?: UpdateOptions,
): Promise<Record<string, unknown>>;
delete(
+20 -4
View File
@@ -15,6 +15,7 @@ import {
type ListOptions,
NotFoundError,
type SearchOptions,
type UpdateOptions,
} from "./base.js";
function encodePathSegment(value: unknown): string {
@@ -150,7 +151,13 @@ export class PlatformBackend implements Backend {
if (opts.immutable) payload.immutable = true;
if (opts.infer === false) payload.infer = false;
if (opts.expires) payload.expiration_date = opts.expires;
if (opts.categories) payload.categories = opts.categories;
if (opts.customInstructions)
payload.custom_instructions = opts.customInstructions;
if (opts.customCategories)
payload.custom_categories = opts.customCategories;
if (opts.structuredDataSchema)
payload.structured_data_schema = opts.structuredDataSchema;
if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp;
payload.source = "CLI";
return (await this._request("POST", "/v3/memories/add/", {
@@ -211,6 +218,10 @@ export class PlatformBackend implements Backend {
if (opts.rerank) payload.rerank = true;
if (opts.keyword) payload.keyword_search = true;
if (opts.fields) payload.fields = opts.fields;
if (opts.showExpired) payload.show_expired = true;
if (opts.referenceDate !== undefined)
payload.reference_date = opts.referenceDate;
if (opts.latestOnly) payload.latest_only = true;
payload.source = "CLI";
const result = (await this._request("POST", "/v3/memories/search/", {
@@ -265,6 +276,8 @@ export class PlatformBackend implements Backend {
extraFilters: Object.keys(extra).length > 0 ? extra : undefined,
});
if (apiFilters) payload.filters = apiFilters;
if (opts.showExpired) payload.show_expired = true;
if (opts.latestOnly) payload.latest_only = true;
payload.source = "CLI";
const result = (await this._request("POST", "/v3/memories/", {
@@ -280,10 +293,13 @@ export class PlatformBackend implements Backend {
memoryId: string,
content?: string,
metadata?: Record<string, unknown>,
opts: UpdateOptions = {},
): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
if (content) payload.text = content;
if (metadata) payload.metadata = metadata;
if (opts.expirationDate) payload.expiration_date = opts.expirationDate;
if (opts.timestamp !== undefined) payload.timestamp = opts.timestamp;
payload.source = "CLI";
return (await this._request(
"PUT",
@@ -309,12 +325,12 @@ export class PlatformBackend implements Backend {
})) as Record<string, unknown>;
}
if (memoryId) {
const params: Record<string, string> = { source: "CLI" };
if (opts.deleteLinked) params.delete_linked = "true";
return (await this._request(
"DELETE",
`/v1/memories/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
{ params },
)) as Record<string, unknown>;
}
throw new Error("Either memoryId or --all is required");
+75 -33
View File
@@ -21,16 +21,19 @@ import {
formatSingleMemory,
printResultSummary,
} from "../output.js";
import { isAgentMode, setCurrentCommand } from "../state.js";
import { isAgentMode, setCurrentCommand, stdinIsPiped } from "../state.js";
/** True only when stdin is an actual pipe or file redirect — never in agent mode. */
function _stdinIsPiped(): boolean {
if (isAgentMode()) return false;
try {
const stat = fs.fstatSync(0);
return stat.isFIFO() || stat.isFile();
} catch {
return false;
/** Exit 1 if value is not a future YYYY-MM-DD date. */
function _validateExpires(value: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
printError(
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
);
process.exit(1);
}
if (new Date(value) <= new Date()) {
printError("--expires date must be in the future.");
process.exit(1);
}
}
@@ -49,10 +52,22 @@ export async function cmdAdd(
infer?: boolean;
expires?: string;
categories?: string;
customInstructions?: string;
customCategories?: string;
structuredDataSchema?: string;
timestamp?: number;
output: string;
},
): Promise<void> {
setCurrentCommand("add");
if (opts.categories) {
printError(
"--categories is not supported on add. Use --custom-categories instead.",
);
process.exit(1);
}
let msgs: Record<string, unknown>[] | undefined;
let content = text;
@@ -78,7 +93,7 @@ export async function cmdAdd(
}
}
// Read from stdin only if stdin is an actual pipe or file redirect
else if (!content && _stdinIsPiped()) {
else if (!content && stdinIsPiped()) {
content = fs.readFileSync(0, "utf-8").trim();
}
@@ -93,20 +108,6 @@ export async function cmdAdd(
process.exit(1);
}
// Validate --expires
if (opts.expires) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.expires)) {
printError(
"Invalid date format for --expires. Use YYYY-MM-DD (e.g. 2025-12-31).",
);
process.exit(1);
}
if (new Date(opts.expires) <= new Date()) {
printError("--expires date must be in the future.");
process.exit(1);
}
}
let meta: Record<string, unknown> | undefined;
if (opts.metadata) {
try {
@@ -117,15 +118,28 @@ export async function cmdAdd(
}
}
let cats: string[] | undefined;
if (opts.categories) {
let customCats: Record<string, string>[] | undefined;
if (opts.customCategories) {
try {
cats = JSON.parse(opts.categories);
customCats = JSON.parse(opts.customCategories);
} catch {
cats = opts.categories.split(",").map((c) => c.trim());
printError("Invalid JSON in --custom-categories.");
process.exit(1);
}
}
let schema: Record<string, unknown> | undefined;
if (opts.structuredDataSchema) {
try {
schema = JSON.parse(opts.structuredDataSchema);
} catch {
printError("Invalid JSON in --structured-data-schema.");
process.exit(1);
}
}
if (opts.expires) _validateExpires(opts.expires);
let result: Record<string, unknown>;
try {
result = await timedStatus("Adding memory...", async () => {
@@ -138,7 +152,10 @@ export async function cmdAdd(
immutable: opts.immutable,
infer: opts.infer !== false,
expires: opts.expires,
categories: cats,
customInstructions: opts.customInstructions,
customCategories: customCats,
structuredDataSchema: schema,
timestamp: opts.timestamp,
});
});
} catch (e) {
@@ -223,6 +240,9 @@ export async function cmdSearch(
keyword: boolean;
filterJson?: string;
fields?: string;
showExpired?: boolean;
referenceDate?: string;
latestOnly?: boolean;
output: string;
},
): Promise<void> {
@@ -271,6 +291,9 @@ export async function cmdSearch(
keyword: opts.keyword,
filters,
fields: fieldList,
showExpired: opts.showExpired,
referenceDate: opts.referenceDate,
latestOnly: opts.latestOnly,
});
});
} catch (e) {
@@ -364,6 +387,8 @@ export async function cmdList(
category?: string;
after?: string;
before?: string;
showExpired?: boolean;
latestOnly?: boolean;
output: string;
},
): Promise<void> {
@@ -391,6 +416,8 @@ export async function cmdList(
category: opts.category,
after: opts.after,
before: opts.before,
showExpired: opts.showExpired,
latestOnly: opts.latestOnly,
});
});
} catch (e) {
@@ -450,7 +477,12 @@ export async function cmdUpdate(
backend: Backend,
memoryId: string,
text: string | undefined,
opts: { metadata?: string; output: string },
opts: {
metadata?: string;
expires?: string;
timestamp?: number;
output: string;
},
): Promise<void> {
setCurrentCommand("update");
let meta: Record<string, unknown> | undefined;
@@ -463,11 +495,16 @@ export async function cmdUpdate(
}
}
if (opts.expires) _validateExpires(opts.expires);
const start = performance.now();
let result: Record<string, unknown>;
try {
result = await timedStatus("Updating memory...", async () => {
return backend.update(memoryId, text, meta);
return backend.update(memoryId, text, meta, {
expirationDate: opts.expires,
timestamp: opts.timestamp,
});
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
@@ -493,7 +530,12 @@ export async function cmdUpdate(
export async function cmdDelete(
backend: Backend,
memoryId: string,
opts: { output: string; dryRun?: boolean; force?: boolean },
opts: {
output: string;
dryRun?: boolean;
force?: boolean;
deleteLinked?: boolean;
},
): Promise<void> {
setCurrentCommand("delete");
if (opts.dryRun) {
@@ -514,7 +556,7 @@ export async function cmdDelete(
let result: Record<string, unknown>;
try {
result = await timedStatus("Deleting...", async () => {
return backend.delete(memoryId);
return backend.delete(memoryId, { deleteLinked: opts.deleteLinked });
});
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
+55 -3
View File
@@ -17,6 +17,7 @@ import {
isAgentMode,
setAgentMode,
setCurrentCommand,
stdinIsPiped,
takeNotice,
} from "./state.js";
import { captureEvent } from "./telemetry.js";
@@ -319,7 +320,25 @@ program
.option("--immutable", "Prevent future updates.", false)
.option("--no-infer", "Skip inference, store raw.")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--categories <value>", "Categories (JSON array or comma-separated).")
.option(
"--categories <value>",
"Not supported on add, use --custom-categories instead.",
)
.option(
"--custom-instructions <text>",
"Custom instructions for fact extraction.",
)
.option(
"--custom-categories <json>",
"Custom categories as a JSON array of {name: description} objects.",
)
.option(
"--structured-data-schema <json>",
"Schema for structured data extraction, as JSON.",
)
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output format: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
@@ -366,6 +385,16 @@ program
.option("--keyword", "Use keyword search.", false)
.option("--filter <json>", "Advanced filter expression (JSON).")
.option("--fields <list>", "Specific fields to return (comma-separated).")
.option("--show-expired", "Include expired memories.", false)
.option(
"--reference-date <date>",
"Reference date for relative queries (YYYY-MM-DD or unix timestamp).",
)
.option(
"--latest-only",
"Only return the latest version of each memory.",
false,
)
.option("-o, --output <format>", "Output: text, json, table.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
@@ -375,7 +404,7 @@ program
)
.action(async (query, opts) => {
let resolvedQuery = query;
if (!resolvedQuery && !process.stdin.isTTY) {
if (!resolvedQuery && stdinIsPiped()) {
resolvedQuery = fs.readFileSync(0, "utf-8").trim();
}
if (!resolvedQuery) {
@@ -398,6 +427,9 @@ program
keyword: opts.keyword,
filterJson: opts.filter,
fields: opts.fields,
showExpired: opts.showExpired,
referenceDate: opts.referenceDate,
latestOnly: opts.latestOnly,
output,
});
});
@@ -441,6 +473,12 @@ program
.option("--category <name>", "Filter by category.")
.option("--after <date>", "Created after (YYYY-MM-DD).")
.option("--before <date>", "Created before (YYYY-MM-DD).")
.option("--show-expired", "Include expired memories.", false)
.option(
"--latest-only",
"Only return the latest version of each memory.",
false,
)
.option("-o, --output <format>", "Output: text, json, table.", "table")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
@@ -464,6 +502,8 @@ program
category: opts.category,
after: opts.after,
before: opts.before,
showExpired: opts.showExpired,
latestOnly: opts.latestOnly,
output,
});
});
@@ -474,6 +514,10 @@ program
.command("update <memoryId> [text]")
.description("Update a memory's text or metadata.")
.option("-m, --metadata <json>", "Update metadata (JSON).")
.option("--expires <date>", "Expiration date (YYYY-MM-DD).")
.option("--timestamp <unix>", "Unix timestamp for the memory.", (v) =>
Number.parseInt(v),
)
.option("-o, --output <format>", "Output: text, json, quiet.", "text")
.option("--api-key <key>", "Override API key.")
.option("--base-url <url>", "Override API base URL.")
@@ -483,7 +527,7 @@ program
)
.action(async (memoryId, text, opts) => {
let resolvedText = text;
if (!resolvedText && !opts.metadata && !process.stdin.isTTY) {
if (!resolvedText && stdinIsPiped()) {
resolvedText = fs.readFileSync(0, "utf-8").trim();
}
const { cmdUpdate } = await import("./commands/memory.js");
@@ -492,6 +536,8 @@ program
const output = isAgent ? "agent" : opts.output;
await cmdUpdate(backend, memoryId, resolvedText, {
metadata: opts.metadata,
expires: opts.expires,
timestamp: opts.timestamp,
output,
});
});
@@ -510,6 +556,11 @@ program
.option("--project", "With --all: delete ALL memories project-wide.", false)
.option("--dry-run", "Show what would be deleted without deleting.", false)
.option("--force", "Skip confirmation.", false)
.option(
"--delete-linked",
"Also delete memories linked to this memory.",
false,
)
.option("-u, --user-id <id>", "Scope to user.")
.option("--agent-id <id>", "Scope to agent.")
.option("--app-id <id>", "Scope to app.")
@@ -563,6 +614,7 @@ program
output,
dryRun: opts.dryRun,
force: opts.force,
deleteLinked: opts.deleteLinked,
});
return;
}
+17 -3
View File
@@ -282,11 +282,24 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
}
case "search":
return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "score", "created_at", "categories"]),
pick(r, [
"id",
"memory",
"score",
"created_at",
"categories",
"expiration_date",
]),
);
case "list":
return (data as Record<string, unknown>[]).map((r) =>
pick(r, ["id", "memory", "created_at", "categories"]),
pick(r, [
"id",
"memory",
"created_at",
"categories",
"expiration_date",
]),
);
case "get": {
const r = data as Record<string, unknown>;
@@ -297,11 +310,12 @@ export function sanitizeAgentData(command: string, data: unknown): unknown {
"updated_at",
"categories",
"metadata",
"expiration_date",
]);
}
case "update": {
const r = data as Record<string, unknown>;
return pick(r, ["id", "memory"]);
return pick(r, ["id", "memory", "expiration_date"]);
}
case "delete":
case "delete-all":
+13
View File
@@ -3,6 +3,8 @@
* read by commands and branding functions.
*/
import fs from "node:fs";
let _agentMode = false;
let _currentCommand = "";
let _pendingNotice = "";
@@ -38,3 +40,14 @@ export function takeNotice(): string {
_pendingNotice = "";
return msg;
}
/** True only when stdin is an actual pipe or file redirect (never in agent mode). */
export function stdinIsPiped(): boolean {
if (isAgentMode()) return false;
try {
const stat = fs.fstatSync(0);
return stat.isFIFO() || stat.isFile();
} catch {
return false;
}
}
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
/**
* Drift test: every documented v3 add/search/list param must be reachable from the Node CLI.
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const OPENAPI_PATH = path.join(
__dirname,
"..",
"..",
"..",
"docs",
"openapi.json",
);
const KNOWN_UNSURFACED: Record<string, Record<string, string>> = {};
const ADD_MAPPING: Record<string, string[]> = {
messages: ["--messages", "--file", "text"],
user_id: ["--user-id"],
agent_id: ["--agent-id"],
run_id: ["--run-id"],
metadata: ["--metadata"],
expiration_date: ["--expires"],
custom_instructions: ["--custom-instructions"],
custom_categories: ["--custom-categories"],
infer: ["--no-infer"],
};
const SEARCH_MAPPING: Record<string, string[]> = {
query: ["query"],
filters: ["--filter", "--user-id", "--agent-id", "--run-id"],
show_expired: ["--show-expired"],
top_k: ["--top-k"],
threshold: ["--threshold"],
rerank: ["--rerank"],
reference_date: ["--reference-date"],
};
const LIST_MAPPING: Record<string, string[]> = {
filters: [
"--user-id",
"--agent-id",
"--run-id",
"--category",
"--after",
"--before",
],
show_expired: ["--show-expired"],
};
function documentedFields(endpoint: string): string[] {
const spec = JSON.parse(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const schema =
spec.paths[endpoint].post.requestBody.content["application/json"].schema;
return Object.keys(schema.properties);
}
function helpText(command: string): string {
return execSync(`npx tsx src/index.ts ${command} --help`, {
cwd: path.join(__dirname, ".."),
encoding: "utf-8",
timeout: 15000,
});
}
function assertAllReachable(
endpoint: string,
mapping: Record<string, string[]>,
command: string,
) {
const documented = documentedFields(endpoint);
const help = helpText(command);
for (const field of documented) {
if (KNOWN_UNSURFACED[endpoint]?.[field]) continue;
const candidates = mapping[field];
expect(
candidates,
`${endpoint}: documented field "${field}" has no mapping entry for command "${command}"`,
).toBeDefined();
const reachable = candidates.some((flag) =>
flag.startsWith("--") ? help.includes(flag) : true,
);
expect(
reachable,
`${endpoint}: documented field "${field}" not reachable via any of ${JSON.stringify(candidates)} on command "${command}"`,
).toBe(true);
}
}
describe("Option parity: Node CLI reachability of documented v3 params", () => {
it("add covers documented fields", () => {
assertAllReachable("/v3/memories/add/", ADD_MAPPING, "add");
});
it("search covers documented fields", () => {
assertAllReachable("/v3/memories/search/", SEARCH_MAPPING, "search");
});
it("list covers documented fields", () => {
assertAllReachable("/v3/memories/", LIST_MAPPING, "list");
});
});
describe("stdin fallback uses the shared piped-stdin guard", () => {
const SOURCES = ["src/index.ts", "src/commands/memory.ts"];
for (const rel of SOURCES) {
it(`${rel} never checks process.stdin.isTTY directly`, () => {
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
expect(
src.includes("process.stdin.isTTY"),
`${rel}: use stdinIsPiped() from state.ts. A bare !isTTY check is also true for /dev/null and sockets, so readFileSync(0) crashes with EAGAIN in scripts, CI, and agent mode.`,
).toBe(false);
});
it(`${rel} guards every readFileSync(0) with stdinIsPiped()`, () => {
const src = fs.readFileSync(path.join(__dirname, "..", rel), "utf-8");
const lines = src.split("\n");
for (const [i, line] of lines.entries()) {
if (!line.includes("readFileSync(0")) continue;
const guard = lines.slice(Math.max(0, i - 3), i).join("\n");
expect(
guard.includes("stdinIsPiped()"),
`${rel}:${i + 1}: readFileSync(0) must be guarded by stdinIsPiped()`,
).toBe(true);
}
});
}
});
+205 -68
View File
@@ -7,94 +7,231 @@ import { PlatformBackend } from "../src/backend/platform.js";
import { createDefaultConfig } from "../src/config.js";
function makeBackend(): PlatformBackend {
// apiKey/baseUrl only build request headers; every test spies on _request,
// so no real network calls are made.
return new PlatformBackend(createDefaultConfig().platform);
return new PlatformBackend(createDefaultConfig().platform);
}
function mockFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue(null) },
json: vi.fn().mockResolvedValue({ message: "ok" }),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: vi.fn().mockReturnValue(null) },
json: vi.fn().mockResolvedValue({ message: "ok" }),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("deleteEntities", () => {
it("returns all results keyed by entity type for a multi-entity delete", async () => {
const backend = makeBackend();
const responses: Record<string, unknown> = {
"/v2/entities/user/alice/": { message: "user deleted" },
"/v2/entities/agent/bob/": { message: "agent deleted" },
};
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockImplementation(async (_method: string, path: string) => responses[path]);
it("returns all results keyed by entity type for a multi-entity delete", async () => {
const backend = makeBackend();
const responses: Record<string, unknown> = {
"/v2/entities/user/alice/": { message: "user deleted" },
"/v2/entities/agent/bob/": { message: "agent deleted" },
};
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockImplementation(
async (_method: string, path: string) => responses[path],
);
const result = await backend.deleteEntities({ userId: "alice", agentId: "bob" });
const result = await backend.deleteEntities({
userId: "alice",
agentId: "bob",
});
// Regression: previously only the last entity's response survived.
expect(result).toEqual({
user: { message: "user deleted" },
agent: { message: "agent deleted" },
});
expect(spy).toHaveBeenCalledTimes(2);
});
expect(result).toEqual({
user: { message: "user deleted" },
agent: { message: "agent deleted" },
});
expect(spy).toHaveBeenCalledTimes(2);
});
it("keys a single-entity delete by its type", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
vi.spyOn(backend as any, "_request").mockResolvedValue({ message: "user deleted" });
it("keys a single-entity delete by its type", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
vi.spyOn(backend as any, "_request").mockResolvedValue({
message: "user deleted",
});
const result = await backend.deleteEntities({ userId: "alice" });
expect(result).toEqual({ user: { message: "user deleted" } });
});
const result = await backend.deleteEntities({ userId: "alice" });
expect(result).toEqual({ user: { message: "user deleted" } });
});
it("throws when no entity id is provided", async () => {
const backend = makeBackend();
await expect(backend.deleteEntities({})).rejects.toThrow(
"At least one entity ID is required",
);
});
it("throws when no entity id is provided", async () => {
const backend = makeBackend();
await expect(backend.deleteEntities({})).rejects.toThrow(
"At least one entity ID is required",
);
});
});
describe("PlatformBackend option-parity payloads (MEM-5893)", () => {
it("add: custom_instructions, custom_categories, structured_data_schema, timestamp reach the payload alongside existing fields", async () => {
const backend = makeBackend();
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockResolvedValue({ results: [] });
await backend.add("hello", undefined, {
userId: "alice",
metadata: { source: "test" },
expires: "2099-01-01",
customInstructions: "Extract only preferences.",
customCategories: [{ prefs: "user preferences" }],
structuredDataSchema: { type: "object" },
timestamp: 1700000000,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.custom_instructions).toBe("Extract only preferences.");
expect(payload.custom_categories).toEqual([{ prefs: "user preferences" }]);
expect(payload.structured_data_schema).toEqual({ type: "object" });
expect(payload.timestamp).toBe(1700000000);
expect(payload.metadata).toEqual({ source: "test" });
expect(payload.expiration_date).toBe("2099-01-01");
});
it("add: omitted optional fields are absent from the payload", async () => {
const backend = makeBackend();
const spy = vi
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
.spyOn(backend as any, "_request")
.mockResolvedValue({ results: [] });
await backend.add("hello", undefined, { userId: "alice" });
const payload = spy.mock.calls[0][2].json;
expect(payload).not.toHaveProperty("custom_instructions");
expect(payload).not.toHaveProperty("custom_categories");
expect(payload).not.toHaveProperty("structured_data_schema");
expect(payload).not.toHaveProperty("timestamp");
});
it("search: show_expired, reference_date, latest_only reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {
showExpired: true,
referenceDate: "2024-01-01",
latestOnly: true,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.show_expired).toBe(true);
expect(payload.reference_date).toBe("2024-01-01");
expect(payload.latest_only).toBe(true);
});
it("search: keyword_search and fields reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {
keyword: true,
fields: ["memory", "score"],
});
const payload = spy.mock.calls[0][2].json;
expect(payload.keyword_search).toBe(true);
expect(payload.fields).toEqual(["memory", "score"]);
});
it("search: omitted keyword and fields are absent from the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.search("query", {});
const payload = spy.mock.calls[0][2].json;
expect(payload).not.toHaveProperty("keyword_search");
expect(payload).not.toHaveProperty("fields");
});
it("listMemories: show_expired and latest_only are top-level, not nested inside filters", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue([]);
await backend.listMemories({
userId: "alice",
showExpired: true,
latestOnly: true,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.show_expired).toBe(true);
expect(payload.latest_only).toBe(true);
expect(payload.filters ?? {}).not.toHaveProperty("show_expired");
expect(payload.filters ?? {}).not.toHaveProperty("latest_only");
});
it("update: expiration_date and timestamp reach the payload", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({});
await backend.update("mem-123", undefined, undefined, {
expirationDate: "2099-01-01",
timestamp: 1700000000,
});
const payload = spy.mock.calls[0][2].json;
expect(payload.expiration_date).toBe("2099-01-01");
expect(payload.timestamp).toBe(1700000000);
});
it("delete: delete_linked is a query param, not part of the JSON body", async () => {
const backend = makeBackend();
// biome-ignore lint/suspicious/noExplicitAny: spying on a private method
const spy = vi.spyOn(backend as any, "_request").mockResolvedValue({});
await backend.delete("mem-123", { deleteLinked: true });
const opts = spy.mock.calls[0][2];
expect(opts.params.delete_linked).toBe("true");
expect(opts.json).toBeUndefined();
});
});
describe("PlatformBackend path encoding", () => {
it("encodes memory IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
it("encodes memory IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
await backend.get("mem/a?b#c");
await backend.update("mem/a?b#c", "updated");
await backend.delete("mem/a?b#c");
await backend.get("mem/a?b#c");
await backend.update("mem/a?b#c", "updated");
await backend.delete("mem/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
]);
});
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
]);
});
it("encodes entity and event IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
it("encodes entity and event IDs before interpolating them into paths", async () => {
const fetchMock = mockFetch();
const backend = makeBackend();
await backend.deleteEntities({ userId: "org/team?active#frag" });
await backend.getEvent("evt/a?b#c");
await backend.deleteEntities({ userId: "org/team?active#frag" });
await backend.getEvent("evt/a?b#c");
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
]);
});
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
]);
});
});