From f1e104d481f48f33bc406b8b95234c632f3e092c Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Thu, 2 Jul 2026 08:45:23 +0530 Subject: [PATCH] fix(tools): repair SDK call mismatches that broke five tools Several @supermemory/tools operations were written against a different supermemory SDK surface than the v3 SDK the package pins, so they failed on every invocation: - documentDelete (ai-sdk) passed an object to documents.delete, which takes a plain id string - no document could ever be deleted. - memoryForget (ai-sdk + openai) called client.memories.forget, which does not exist in the v3 SDK - every call threw and surfaced as success:false. It now hits DELETE /v4/memories directly via a shared helper, the same raw-fetch pattern the middleware already uses for /v4/profile and /v4/conversations. - documentList (ai-sdk + openai) read response.documents, but the list response keys its items as memories - the tool always returned documents: undefined. It also forwarded offset and status params the API does not have; the schema now exposes the API's real page-based pagination instead. - ClaudeMemoryTool.str_replace rejected new_str: "" (the standard way to delete text) because of a falsy check; insert likewise rejected inserting a blank line. Both now only reject missing values. - ClaudeMemoryTool.delete reported success without deleting anything, and rename left the old document in place, so "deleted" files kept showing up in listings and search. Both now call documents.delete. Testing: new offline unit suite (tool-operations.test.ts) mocks the SDK and global fetch and verifies each call shape; 9 of its 13 tests fail against the previous code. Updated the stale getToolDefinitions count (2 -> 7). tsc --noEmit error count drops from 155 to 148 - the change removes the eight SDK-mismatch errors and introduces none (the remaining errors are the pre-existing zod/ai schema-typing issue that affects every tool() call in the package). --- packages/tools/src/ai-sdk.ts | 34 ++- packages/tools/src/claude-memory.ts | 26 ++- packages/tools/src/openai/tools.ts | 40 ++-- packages/tools/src/shared/forget-memory.ts | 38 ++++ packages/tools/src/tool-operations.test.ts | 253 +++++++++++++++++++++ packages/tools/src/tools-shared.ts | 6 +- packages/tools/src/tools.test.ts | 2 +- 7 files changed, 349 insertions(+), 50 deletions(-) create mode 100644 packages/tools/src/shared/forget-memory.ts create mode 100644 packages/tools/src/tool-operations.test.ts diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 77f3ad03f..48d2bae78 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -7,6 +7,7 @@ import { TOOL_DESCRIPTIONS, getContainerTags, } from "./tools-shared" +import { forgetMemoryRequest } from "./shared/forget-memory" import type { SupermemoryToolsConfig } from "./types" // Export individual tool creators @@ -191,23 +192,21 @@ export const documentListTool = ( .optional() .default(DEFAULT_VALUES.limit) .describe(PARAMETER_DESCRIPTIONS.limit), - offset: z.number().optional().describe(PARAMETER_DESCRIPTIONS.offset), - status: z.string().optional().describe(PARAMETER_DESCRIPTIONS.status), + page: z.number().optional().describe(PARAMETER_DESCRIPTIONS.page), }), - execute: async ({ containerTag, limit, offset, status }) => { + execute: async ({ containerTag, limit, page }) => { try { const tag = containerTag || containerTags[0] const response = await client.documents.list({ containerTags: [tag], limit: limit || DEFAULT_VALUES.limit, - ...(offset !== undefined && { offset }), - ...(status && { status }), + ...(page !== undefined && { page }), }) return { success: true, - documents: response.documents, + documents: response.memories, pagination: response.pagination, } } catch (error) { @@ -236,7 +235,7 @@ export const documentDeleteTool = ( }), execute: async ({ documentId }) => { try { - await client.documents.delete({ docId: documentId }) + await client.documents.delete(documentId) return { success: true, @@ -303,11 +302,6 @@ export const memoryForgetTool = ( apiKey: string, config?: SupermemoryToolsConfig, ) => { - const client = new Supermemory({ - apiKey, - ...(config?.baseUrl ? { baseURL: config.baseUrl } : {}), - }) - const containerTags = getContainerTags(config) return tool({ @@ -335,12 +329,16 @@ export const memoryForgetTool = ( const tag = containerTag || containerTags[0] - await client.memories.forget({ - containerTag: tag, - ...(memoryId && { id: memoryId }), - ...(memoryContent && { content: memoryContent }), - ...(reason && { reason }), - }) + await forgetMemoryRequest( + apiKey, + { + containerTag: tag as string, + ...(memoryId && { id: memoryId }), + ...(memoryContent && { content: memoryContent }), + ...(reason && { reason }), + }, + config?.baseUrl, + ) return { success: true, diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index bbb605476..6830b296e 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -96,7 +96,10 @@ export class ClaudeMemoryTool { } return await this.create(command.path, command.file_text) case "str_replace": - if (!command.old_str || !command.new_str) { + // new_str may legitimately be "" (deleting text), so only reject + // when it is missing entirely. old_str must be non-empty — replacing + // the empty string would prepend instead of replacing. + if (!command.old_str || command.new_str === undefined) { return { success: false, error: "old_str and new_str are required for str_replace command", @@ -108,7 +111,11 @@ export class ClaudeMemoryTool { command.new_str, ) case "insert": - if (command.insert_line === undefined || !command.insert_text) { + // insert_text may be "" (inserting a blank line). + if ( + command.insert_line === undefined || + command.insert_text === undefined + ) { return { success: false, error: @@ -487,9 +494,9 @@ export class ClaudeMemoryTool { } } - // Delete using the document ID - // Note: We'll need to implement this based on supermemory's delete API - // For now, we'll return a success message + const documentId = + readResult.document.documentId ?? this.normalizePathToCustomId(filePath) + await this.client.documents.delete(documentId) return { success: true, @@ -544,7 +551,14 @@ export class ClaudeMemoryTool { }, }) - // Delete the old document (would need proper delete API) + // Remove the old document so the previous path stops showing up in + // listings and search. Skip when both paths normalize to the same + // customId — the add above already replaced the content. + const oldNormalizedId = this.normalizePathToCustomId(oldPath) + if (oldNormalizedId !== newNormalizedId) { + const oldDocumentId = readResult.document.documentId ?? oldNormalizedId + await this.client.documents.delete(oldDocumentId) + } return { success: true, diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index da7d6642e..4695c9205 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -6,6 +6,7 @@ import { TOOL_DESCRIPTIONS, getContainerTags, } from "../tools-shared" +import { forgetMemoryRequest } from "../shared/forget-memory" import type { SupermemoryToolsConfig } from "../types" /** @@ -36,7 +37,7 @@ export interface ProfileResult { export interface DocumentListResult { success: boolean - documents?: Awaited>["documents"] + documents?: Awaited>["memories"] pagination?: Awaited< ReturnType >["pagination"] @@ -139,13 +140,9 @@ export const memoryToolSchemas = { description: PARAMETER_DESCRIPTIONS.limit, default: DEFAULT_VALUES.limit, }, - offset: { + page: { type: "number", - description: PARAMETER_DESCRIPTIONS.offset, - }, - status: { - type: "string", - description: PARAMETER_DESCRIPTIONS.status, + description: PARAMETER_DESCRIPTIONS.page, }, }, required: [], @@ -359,13 +356,11 @@ export function createDocumentListFunction( return async function documentList({ containerTag, limit, - offset, - status, + page, }: { containerTag?: string limit?: number - offset?: number - status?: string + page?: number }): Promise { try { const tag = containerTag || containerTags[0] @@ -373,13 +368,12 @@ export function createDocumentListFunction( const response = await client.documents.list({ containerTags: [tag], limit: limit || DEFAULT_VALUES.limit, - ...(offset !== undefined && { offset }), - ...(status && { status }), + ...(page !== undefined && { page }), }) return { success: true, - documents: response.documents, + documents: response.memories, pagination: response.pagination, } } catch (error) { @@ -470,7 +464,7 @@ export function createMemoryForgetFunction( apiKey: string, config?: SupermemoryToolsConfig, ) { - const { client, containerTags } = createClient(apiKey, config) + const containerTags = getContainerTags(config) return async function memoryForget({ containerTag, @@ -493,12 +487,16 @@ export function createMemoryForgetFunction( const tag = containerTag || containerTags[0] - await client.memories.forget({ - containerTag: tag, - ...(memoryId && { id: memoryId }), - ...(memoryContent && { content: memoryContent }), - ...(reason && { reason }), - }) + await forgetMemoryRequest( + apiKey, + { + containerTag: tag as string, + ...(memoryId && { id: memoryId }), + ...(memoryContent && { content: memoryContent }), + ...(reason && { reason }), + }, + config?.baseUrl, + ) return { success: true, diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts new file mode 100644 index 000000000..50a3a5294 --- /dev/null +++ b/packages/tools/src/shared/forget-memory.ts @@ -0,0 +1,38 @@ +const DEFAULT_BASE_URL = "https://api.supermemory.ai" + +export interface ForgetMemoryParams { + containerTag: string + id?: string + content?: string + reason?: string +} + +/** + * Marks a memory as forgotten via `DELETE /v4/memories`. + * + * The supermemory SDK version this package depends on (v3) has no + * `memories.forget` method, so the endpoint is called directly — the same + * pattern the middleware already uses for `/v4/profile` and + * `/v4/conversations`. + */ +export async function forgetMemoryRequest( + apiKey: string, + params: ForgetMemoryParams, + baseUrl: string = DEFAULT_BASE_URL, +): Promise { + const response = await fetch(`${baseUrl}/v4/memories`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(params), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unknown error") + throw new Error( + `Supermemory forget memory failed: ${response.status} ${response.statusText}. ${errorText}`, + ) + } +} diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts new file mode 100644 index 000000000..69f125940 --- /dev/null +++ b/packages/tools/src/tool-operations.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool +// executions can be verified deterministically without network access. +const documentsDelete = vi.fn() +const documentsList = vi.fn() +const searchExecute = vi.fn() +const clientAdd = vi.fn() + +vi.mock("supermemory", () => { + return { + default: class MockSupermemory { + search = { execute: searchExecute } + add = clientAdd + documents = { + delete: documentsDelete, + list: documentsList, + add: vi.fn(), + } + }, + } +}) + +import * as aiSdk from "./ai-sdk" +import { ClaudeMemoryTool } from "./claude-memory" +import { forgetMemoryRequest } from "./shared/forget-memory" +import * as openAi from "./openai/tools" + +const API_KEY = "sm_test_key" + +type ToolWithExecute = { execute: (args: Record) => unknown } + +function executeTool(tool: unknown, args: Record) { + return (tool as ToolWithExecute).execute(args) +} + +beforeEach(() => { + documentsDelete.mockReset().mockResolvedValue(undefined) + documentsList.mockReset().mockResolvedValue({ + memories: [{ id: "doc_1", title: "Doc one" }], + pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, + }) + searchExecute.mockReset() + clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) + vi.unstubAllGlobals() +}) + +describe("documentDelete", () => { + it("ai-sdk variant passes the document id string to the SDK", async () => { + const tool = aiSdk.documentDeleteTool(API_KEY) + const result = (await executeTool(tool, { documentId: "doc_123" })) as { + success: boolean + } + + expect(result.success).toBe(true) + expect(documentsDelete).toHaveBeenCalledWith("doc_123") + }) +}) + +describe("documentList", () => { + it("ai-sdk variant returns the SDK's memories array as documents", async () => { + const tool = aiSdk.documentListTool(API_KEY) + const result = (await executeTool(tool, {})) as { + success: boolean + documents?: Array<{ id: string }> + } + + expect(result.success).toBe(true) + expect(result.documents).toEqual([{ id: "doc_1", title: "Doc one" }]) + }) + + it("openai variant forwards page-based pagination to the SDK", async () => { + const documentList = openAi.createDocumentListFunction(API_KEY) + const result = await documentList({ limit: 5, page: 3 }) + + expect(result.success).toBe(true) + expect(result.documents).toEqual([{ id: "doc_1", title: "Doc one" }]) + expect(documentsList).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5, page: 3 }), + ) + }) +}) + +describe("memoryForget", () => { + function stubFetch(response = new Response(null, { status: 200 })) { + const fetchMock = vi.fn().mockResolvedValue(response) + vi.stubGlobal("fetch", fetchMock) + return fetchMock + } + + it("issues DELETE /v4/memories with the forget payload", async () => { + const fetchMock = stubFetch() + + await forgetMemoryRequest(API_KEY, { + containerTag: "user_1", + id: "mem_1", + reason: "outdated", + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe("https://api.supermemory.ai/v4/memories") + expect(init.method).toBe("DELETE") + expect(init.headers).toMatchObject({ + Authorization: `Bearer ${API_KEY}`, + }) + expect(JSON.parse(init.body as string)).toEqual({ + containerTag: "user_1", + id: "mem_1", + reason: "outdated", + }) + }) + + it("throws a descriptive error on non-2xx responses", async () => { + stubFetch(new Response("nope", { status: 401, statusText: "Unauthorized" })) + + await expect( + forgetMemoryRequest(API_KEY, { containerTag: "user_1", id: "mem_1" }), + ).rejects.toThrow(/401/) + }) + + it("ai-sdk tool forgets by content through the endpoint", async () => { + const fetchMock = stubFetch() + const tool = aiSdk.memoryForgetTool(API_KEY, { + containerTags: ["user_2"], + }) + + const result = (await executeTool(tool, { + memoryContent: "stale fact", + })) as { success: boolean } + + expect(result.success).toBe(true) + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(init.body as string)).toEqual({ + containerTag: "user_2", + content: "stale fact", + }) + }) + + it("openai tool surfaces endpoint failures as tool errors", async () => { + stubFetch(new Response("boom", { status: 500, statusText: "Server Error" })) + const memoryForget = openAi.createMemoryForgetFunction(API_KEY) + + const result = await memoryForget({ memoryId: "mem_9" }) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/500/) + }) + + it("still requires an id or content", async () => { + const fetchMock = stubFetch() + const memoryForget = openAi.createMemoryForgetFunction(API_KEY) + + const result = await memoryForget({}) + + expect(result.success).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe("ClaudeMemoryTool", () => { + const FILE_PATH = "/memories/prefs.txt" + const CUSTOM_ID = "memories_prefs_txt" + + function mockFileDocument(content: string) { + searchExecute.mockResolvedValue({ + results: [ + { + documentId: CUSTOM_ID, + content, + metadata: { file_path: FILE_PATH }, + }, + ], + }) + } + + it("str_replace accepts an empty new_str to delete text", async () => { + mockFileDocument("keep this\nremove this\n") + const tool = new ClaudeMemoryTool(API_KEY) + + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "remove this\n", + new_str: "", + }) + + expect(result.success).toBe(true) + expect(clientAdd).toHaveBeenCalledWith( + expect.objectContaining({ content: "keep this\n" }), + ) + }) + + it("str_replace still rejects a missing new_str", async () => { + const tool = new ClaudeMemoryTool(API_KEY) + + const result = await tool.handleCommand({ + command: "str_replace", + path: FILE_PATH, + old_str: "something", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("new_str") + }) + + it("insert accepts an empty insert_text for blank lines", async () => { + mockFileDocument("line1\nline2") + const tool = new ClaudeMemoryTool(API_KEY) + + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 2, + insert_text: "", + }) + + expect(result.success).toBe(true) + expect(clientAdd).toHaveBeenCalledWith( + expect.objectContaining({ content: "line1\n\nline2" }), + ) + }) + + it("delete actually deletes the backing document", async () => { + mockFileDocument("contents") + const tool = new ClaudeMemoryTool(API_KEY) + + const result = await tool.handleCommand({ + command: "delete", + path: FILE_PATH, + }) + + expect(result.success).toBe(true) + expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + }) + + it("rename removes the old document after creating the new one", async () => { + mockFileDocument("contents") + const tool = new ClaudeMemoryTool(API_KEY) + + const result = await tool.handleCommand({ + command: "rename", + path: FILE_PATH, + new_path: "/memories/renamed.txt", + }) + + expect(result.success).toBe(true) + expect(clientAdd).toHaveBeenCalledWith( + expect.objectContaining({ customId: "memories_renamed_txt" }), + ) + expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID) + }) +}) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index ac5dfcd39..537b81bd4 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -11,7 +11,7 @@ export const TOOL_DESCRIPTIONS = { getProfile: "Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query.", documentList: - "List stored documents with optional filtering by container tag, status, and pagination. Useful for browsing or managing saved content.", + "List stored documents with optional filtering by container tag and page-based pagination. Useful for browsing or managing saved content.", documentDelete: "Delete a document and its associated memories by document ID or customId. Deletes are permanent. Use when user wants to remove saved content.", documentAdd: @@ -30,9 +30,7 @@ export const PARAMETER_DESCRIPTIONS = { "The text content of the memory to add. This should be a single sentence or a short paragraph.", containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)", query: "Optional search query to include relevant search results", - offset: "Number of items to skip for pagination (default: 0)", - status: - "Filter documents by processing status (e.g., 'completed', 'processing', 'failed')", + page: "Page number to fetch, 1-based (default: 1)", documentId: "The unique identifier of the document to operate on", content: "The content to add - can be text, URL, or other supported formats", title: "Optional title for the document", diff --git a/packages/tools/src/tools.test.ts b/packages/tools/src/tools.test.ts index 56ef7b62f..a092d5967 100644 --- a/packages/tools/src/tools.test.ts +++ b/packages/tools/src/tools.test.ts @@ -310,7 +310,7 @@ describe("@supermemory/tools", () => { const definitions = openAi.getToolDefinitions() expect(definitions).toBeDefined() - expect(definitions.length).toBe(2) + expect(definitions.length).toBe(7) // Check searchMemories const searchTool = definitions.find(