Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions packages/tools/src/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, string | number | boolean> = {}

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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions packages/tools/src/openai/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -287,11 +288,15 @@ export function createAddMemoryFunction(
try {
const metadata: Record<string, string | number | boolean> = {}

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,
Expand Down
128 changes: 128 additions & 0 deletions packages/tools/src/shared/idempotency.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
108 changes: 108 additions & 0 deletions packages/tools/src/shared/idempotency.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Record<string, string>> {
const key = await generateIdempotencyKey(
content,
containerTags,
now,
customKey,
)
return { "Idempotency-Key": key }
}

export type RetryContext = {
key: string
headers: Record<string, string>
getKey: () => string
getHeaders: () => Record<string, string>
}

/**
* 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<RetryContext> {
const key = await generateIdempotencyKey(
content,
containerTags,
now,
customKey,
)
const headers = { "Idempotency-Key": key }
return {
key,
headers,
getKey: () => key,
getHeaders: () => headers,
}
}
Loading