diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 40290baf2..b40e678d7 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -8,6 +8,7 @@ import { getContainerTags, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" +import { buildIdempotencyHeaders } from "./shared/idempotency" import type { SupermemoryToolsConfig } from "./types" // Export individual tool creators @@ -94,16 +95,37 @@ export const addMemoryTool = ( description: TOOL_DESCRIPTIONS.addMemory, inputSchema: z.object({ memory: z.string().describe(PARAMETER_DESCRIPTIONS.memory), + idempotencyKey: z + .string() + .optional() + .describe( + "Optional custom idempotency key. If provided, used as-is; otherwise SDK generates a deterministic key.", + ), }), - execute: async ({ memory }) => { + execute: async ({ + memory, + idempotencyKey, + }: { + memory: string + idempotencyKey?: string + }) => { try { const metadata: Record = {} - - const response = await client.add({ - content: memory, + const headers = await buildIdempotencyHeaders( + memory, containerTags, - ...(Object.keys(metadata).length > 0 && { metadata }), - }) + Date.now(), + idempotencyKey, + ) + + const response = await client.add( + { + content: memory, + containerTags, + ...(Object.keys(metadata).length > 0 && { metadata }), + }, + { headers }, + ) return { success: true, @@ -278,11 +300,15 @@ export const documentAddTool = ( if (title) metadata.title = title if (description) metadata.description = description - const response = await client.documents.add({ - content, - containerTags, - ...(Object.keys(metadata).length > 0 && { metadata }), - }) + const headers = await buildIdempotencyHeaders(content, containerTags) + const response = await client.documents.add( + { + content, + containerTags, + ...(Object.keys(metadata).length > 0 && { metadata }), + }, + { headers }, + ) return { success: true, diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1ce23cb63..202362195 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -7,6 +7,7 @@ import { getContainerTags, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" +import { buildIdempotencyHeaders } from "../shared/idempotency" import type { SupermemoryToolsConfig } from "../types" /** @@ -287,11 +288,15 @@ export function createAddMemoryFunction( try { const metadata: Record = {} - const response = await client.add({ - content: memory, - containerTags, - ...(Object.keys(metadata).length > 0 && { metadata }), - }) + const headers = await buildIdempotencyHeaders(memory, containerTags) + const response = await client.add( + { + content: memory, + containerTags, + ...(Object.keys(metadata).length > 0 && { metadata }), + }, + { headers }, + ) return { success: true, diff --git a/packages/tools/src/shared/idempotency.test.ts b/packages/tools/src/shared/idempotency.test.ts new file mode 100644 index 000000000..97c2af489 --- /dev/null +++ b/packages/tools/src/shared/idempotency.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest" +import { + buildIdempotencyHeaders, + createRetryContext, + generateIdempotencyKey, +} from "./idempotency" + +describe("idempotency — Phase A (SDK-only, V1)", () => { + it("same content+tags+minute → same key", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("hello", ["a", "b"], now) + const k2 = await generateIdempotencyKey("hello", ["a", "b"], now) + expect(k1).toBe(k2) + expect(k1).toMatch(/^[0-9a-f]{64}$/) + }) + + it("different content → different key", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("hello", ["a"], now) + const k2 = await generateIdempotencyKey("world", ["a"], now) + expect(k1).not.toBe(k2) + }) + + it("different tags → different key", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("hello", ["a"], now) + const k2 = await generateIdempotencyKey("hello", ["b"], now) + expect(k1).not.toBe(k2) + }) + + it("minute rollover → different key", async () => { + const k1 = await generateIdempotencyKey("hello", ["a"], 60_000 * 100) + const k2 = await generateIdempotencyKey("hello", ["a"], 60_000 * 101) + expect(k1).not.toBe(k2) + }) + + it("custom now injection is respected", async () => { + const k1 = await generateIdempotencyKey("x", [], 0) + const k2 = await generateIdempotencyKey("x", [], 60_000) + expect(k1).not.toBe(k2) + }) + + it("header builder returns Idempotency-Key", async () => { + const h = await buildIdempotencyHeaders("hello", ["a"], 1_700_000_000_000) + expect(h).toHaveProperty("Idempotency-Key") + expect(h["Idempotency-Key"]).toMatch(/^[0-9a-f]{64}$/) + }) + + it("retry helper reuses same key within same minute", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("retry me", ["t1"], now) + // Simulate retry 10s later, same minute bucket + const k2 = await generateIdempotencyKey("retry me", ["t1"], now + 10_000) + expect(k1).toBe(k2) + }) + + it("empty content edge — still deterministic", async () => { + const k1 = await generateIdempotencyKey("", [], 1_700_000_000_000) + const k2 = await generateIdempotencyKey("", [], 1_700_000_000_000) + expect(k1).toBe(k2) + expect(k1).toMatch(/^[0-9a-f]{64}$/) + }) + + it("containerTags order independence (sorted)", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("hello", ["b", "a"], now) + const k2 = await generateIdempotencyKey("hello", ["a", "b"], now) + expect(k1).toBe(k2) + }) + + it("concurrent callers share key (parallel generation)", async () => { + const now = 1_700_000_000_000 + const ps = Array.from({ length: 20 }, () => + generateIdempotencyKey("hello", ["a"], now), + ) + const keys = await Promise.all(ps) + expect(new Set(keys).size).toBe(1) + }) + + it("customIdempotencyKey priority — user-provided wins", async () => { + const custom = "my-custom-key-123" + const k = await generateIdempotencyKey( + "hello", + ["a"], + 1_700_000_000_000, + custom, + ) + expect(k).toBe(custom) + const h = await buildIdempotencyHeaders( + "hello", + ["a"], + 1_700_000_000_000, + custom, + ) + expect(h["Idempotency-Key"]).toBe(custom) + }) + + it("RetryContext reuses same key across minute rollover", async () => { + const now = 60_000 * 100 + const ctx = await createRetryContext("hello", ["a"], now) + // 61s later — minute bucket would normally roll, but context reuses original key + const laterKey = await generateIdempotencyKey("hello", ["a"], now + 61_000) + expect(laterKey).not.toBe(ctx.key) // without context, key changes + expect(ctx.getKey()).toBe(ctx.key) + expect(ctx.getHeaders()["Idempotency-Key"]).toBe(ctx.key) + // Simulate retry using context — still same key + expect(ctx.headers["Idempotency-Key"]).toBe(ctx.key) + }) + + it("unicode normalization — café (NFC) and cafe\u0301 (NFD) hash to same key", async () => { + const now = 1_700_000_000_000 + const nfc = "café" // U+00E9 + const nfd = "cafe\u0301" // e + U+0301 + expect(nfc.normalize("NFC")).toBe(nfd.normalize("NFC")) + const k1 = await generateIdempotencyKey(nfc, ["a"], now) + const k2 = await generateIdempotencyKey(nfd, ["a"], now) + expect(k1).toBe(k2) + }) + + it("whitespace trimming — trailing spaces are stable", async () => { + const now = 1_700_000_000_000 + const k1 = await generateIdempotencyKey("hello ", ["a"], now) + const k2 = await generateIdempotencyKey("hello", ["a"], now) + const k3 = await generateIdempotencyKey(" hello ", ["a"], now) + expect(k1).toBe(k2) + expect(k2).toBe(k3) + }) +}) diff --git a/packages/tools/src/shared/idempotency.ts b/packages/tools/src/shared/idempotency.ts new file mode 100644 index 000000000..cfb4dbabe --- /dev/null +++ b/packages/tools/src/shared/idempotency.ts @@ -0,0 +1,108 @@ +/** + * Idempotency — Phase A (SDK-only, V1 + Staff improvements) + * + * Generates a stable `Idempotency-Key` for memory writes so retries + * (network retry, caller retry, offline queue) do not create duplicates. + * The header is optional for the backend in Phase A — useful even before + * the server honors it as a dedupe key. + * + * Key = SHA-256( normalizedContent + '|' + sorted(containerTags).join(',') + '|' + minuteBucket ) + * minuteBucket = floor(now / 60000) — stable within the same minute, rotates after. + * normalizedContent = content.normalize("NFC").trim() — unicode + whitespace stable. + * + * Why SHA-256? + * - Deterministic across runtimes (browser, Node, Workers). + * - Available through Web Crypto in browsers, Node, and Workers. + * - Fixed-size output suitable for HTTP headers. + * - No additional dependency required. + * + * Runtime availability: Web Crypto (`crypto.subtle`) is available in browsers, + * Cloudflare Workers, and Node 20+ (repo `engines.node >=20`). Verified via + * `globalThis.crypto.subtle.digest` — no polyfill needed. All three `addMemory` + * entry points (`ai-sdk`, `openai/tools`, `supermemory` client) use the same path. + * + * Header forwarding: Supermemory client's `client.add(params, { headers })` + * and `client.documents.add(params, { headers })` forward `RequestOptions.headers` + * into the underlying `fetch` call (see `supermemory` `client.mjs` `RequestOptions.headers` + * → `fetchWithTimeout`). This is how `Idempotency-Key` reaches the backend. + * + * Metadata scope: `content` + `containerTags` define identity; `metadata` + * (title/description) is intentionally excluded — identical content+tags within + * the same minute produce the same key (desired for dedupe). Callers needing + * metadata-distinct keys should pass `customIdempotencyKey`. + */ + +function normalizeContent(content: string): string { + return content.normalize("NFC").trim() +} + +export async function generateIdempotencyKey( + content: string, + containerTags: string[] = [], + now: number = Date.now(), + customKey?: string, +): Promise { + if (customKey !== undefined && customKey.length > 0) { + return customKey + } + const minuteBucket = Math.floor(now / 60_000) + const tags = [...containerTags].sort().join(",") + const normalized = normalizeContent(content) + const input = `${normalized}|${tags}|${minuteBucket}` + const bytes = new TextEncoder().encode(input) + const digest = await crypto.subtle.digest("SHA-256", bytes) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") +} + +export async function buildIdempotencyHeaders( + content: string, + containerTags: string[] = [], + now: number = Date.now(), + customKey?: string, +): Promise> { + const key = await generateIdempotencyKey( + content, + containerTags, + now, + customKey, + ) + return { "Idempotency-Key": key } +} + +export type RetryContext = { + key: string + headers: Record + getKey: () => string + getHeaders: () => Record +} + +/** + * Future-proof retry helper — captures the key once and reuses it + * across retries, even across minute rollovers. + * + * const retryCtx = await createRetryContext(content, containerTags) + * await client.add(params, { headers: retryCtx.headers }) + * await client.add(params, { headers: retryCtx.headers }) // same key + */ +export async function createRetryContext( + content: string, + containerTags: string[] = [], + now: number = Date.now(), + customKey?: string, +): Promise { + const key = await generateIdempotencyKey( + content, + containerTags, + now, + customKey, + ) + const headers = { "Idempotency-Key": key } + return { + key, + headers, + getKey: () => key, + getHeaders: () => headers, + } +}