mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(tools): repair SDK call mismatches that broke five tools (#1186)
This commit is contained in:
parent
67b6585346
commit
501613b6bc
7 changed files with 349 additions and 50 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<Supermemory["documents"]["list"]>>["documents"]
|
||||
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["memories"]
|
||||
pagination?: Awaited<
|
||||
ReturnType<Supermemory["documents"]["list"]>
|
||||
>["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<DocumentListResult> {
|
||||
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,
|
||||
|
|
|
|||
38
packages/tools/src/shared/forget-memory.ts
Normal file
38
packages/tools/src/shared/forget-memory.ts
Normal file
|
|
@ -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<void> {
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
253
packages/tools/src/tool-operations.test.ts
Normal file
253
packages/tools/src/tool-operations.test.ts
Normal file
|
|
@ -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<string, unknown>) => unknown }
|
||||
|
||||
function executeTool(tool: unknown, args: Record<string, unknown>) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue