This commit is contained in:
Sravan Jangam 2026-09-26 20:48:53 +03:00 • committed by GitHub
commit d2d3d3bb19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 283 additions and 16 deletions

View file

@ -12,6 +12,7 @@ import {
getContainerTags,
} from "./tools-shared"
import { forgetMemoryRequest } from "./shared/forget-memory"
import { buildIdempotencyHeaders } from "./shared/idempotency"
import type { SupermemoryToolsConfig } from "./types"
function createClient(apiKey: string, config?: SupermemoryToolsConfig) {
@ -102,16 +103,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,
@ -290,11 +312,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,

View file

@ -11,6 +11,7 @@ import {
getContainerTags,
} from "../tools-shared"
import { forgetMemoryRequest } from "../shared/forget-memory"
import { buildIdempotencyHeaders } from "../shared/idempotency"
import type { SupermemoryToolsConfig } from "../types"
/**
@ -297,11 +298,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,

View file

@ -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)
})
})

View file

@ -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,
}
}