diff --git a/apps/docs/integrations/claude-memory.mdx b/apps/docs/integrations/claude-memory.mdx index 6afc7947..e7a97c99 100644 --- a/apps/docs/integrations/claude-memory.mdx +++ b/apps/docs/integrations/claude-memory.mdx @@ -183,7 +183,7 @@ Paths are normalized for storage: `/memories/preferences` is stored as `--memori ```typescript { command: "rename", - path: "/memories/old-name.txt", + old_path: "/memories/old-name.txt", new_path: "/memories/new-name.txt" } ``` diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 00000000..68ea5703 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { SupermemoryClient } from "./index" + +const API_URL = "https://api.example.com" + +describe("SupermemoryClient.getDocuments", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + documents: [], + pagination: { + currentPage: 1, + limit: 200, + totalItems: 0, + totalPages: 0, + }, + }), + ) + vi.stubGlobal("fetch", fetchMock) + return fetchMock + } + + it("cancels through a caller-provided signal", async () => { + const fetchMock = stubFetch() + const controller = new AbortController() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: controller.signal }, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + controller.abort() + expect(init.signal?.aborted).toBe(true) + }) + + it("keeps the timeout when a caller-provided signal is present", async () => { + const timeoutController = new AbortController() + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(timeoutController.signal) + const fetchMock = stubFetch() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: new AbortController().signal }, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + // Firing only the timeout leg aborts the request: a caller signal adds + // cancellation, it does not remove the 30s bound. + timeoutController.abort() + expect(init.signal?.aborted).toBe(true) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 1b575e15..3d9e00da 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -339,7 +339,14 @@ export class SupermemoryClient { options?: { signal?: AbortSignal }, ): Promise { try { - const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) + // Compose rather than choose: a caller-supplied signal must add + // cancellation on top of the timeout, not replace it. + const signal = options?.signal + ? AbortSignal.any([ + options.signal, + AbortSignal.timeout(FETCH_TIMEOUT_MS), + ]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS) const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: { diff --git a/packages/tools/README.md b/packages/tools/README.md index 056ae59c..689c5775 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -263,7 +263,7 @@ The `MemoryPromptData` object provides: #### OpenAI Middleware with Supermemory -The `withSupermemory` function creates an OpenAI client with SuperMemory middleware automatically injected: +The `withSupermemory` function creates an isolated middleware facade around an OpenAI client. It does not mutate the supplied client, so a shared base client can safely be wrapped with different user or conversation options: ```typescript import { withSupermemory } from "@supermemory/tools/openai" diff --git a/packages/tools/package.json b/packages/tools/package.json index 5c6b4086..bfa91a54 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,14 @@ { "name": "@supermemory/tools", "type": "module", - "version": "2.3.0", + "version": "2.4.0", "description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory", "scripts": { "build": "tsdown", "dev": "tsdown --watch --ignore-watch .turbo", "check-types": "tsc --noEmit", "test": "vitest --testTimeout 100000", - "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", + "test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts src/shared/memory-client.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts", "test:watch": "vitest --watch --testTimeout 100000" }, "dependencies": { diff --git a/packages/tools/src/ai-sdk.ts b/packages/tools/src/ai-sdk.ts index 97d6a36b..c7536d51 100644 --- a/packages/tools/src/ai-sdk.ts +++ b/packages/tools/src/ai-sdk.ts @@ -10,6 +10,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" import { forgetMemoryRequest } from "./shared/forget-memory" import type { SupermemoryToolsConfig } from "./types" @@ -146,7 +147,7 @@ export const getProfileTool = ( }), execute: async ({ containerTag, query }) => { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -199,7 +200,7 @@ export const documentListTool = ( execute: async ({ containerTag, limit, page }) => { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -339,12 +340,12 @@ export const memoryForgetTool = ( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), diff --git a/packages/tools/src/claude-memory.test.ts b/packages/tools/src/claude-memory.test.ts index 7337441a..efd993c0 100644 --- a/packages/tools/src/claude-memory.test.ts +++ b/packages/tools/src/claude-memory.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // operations can be exercised deterministically without any network access. const documentsListMock = vi.fn() const documentsGetMock = vi.fn() -const documentsDeleteBulkMock = vi.fn() +const documentsDeleteBulkMock = vi + .fn() + .mockResolvedValue({ success: true, deletedCount: 1 }) const addMock = vi.fn() vi.mock("supermemory", () => { @@ -180,6 +182,81 @@ describe("ClaudeMemoryTool exact-file matching", () => { }) }) +describe("ClaudeMemoryTool insert line semantics", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + documentsListMock.mockReset() + documentsGetMock.mockReset() + addMock.mockReset() + mockDocument(FILE_CONTENT) + tool = new ClaudeMemoryTool("test-api-key") + }) + + // The memory_20250818 spec: insert_text is inserted AFTER line insert_line, + // 0 inserts at the beginning of the file, and the valid range is [0, n_lines]. + + it("insert_line: 0 inserts at the beginning of the file", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 0, + insert_text: "header", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toBe("header\nline1\nline2\nline3\nline4\nline5") + }) + + it("inserts AFTER the given line, not before it", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 2, + insert_text: "after2", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + // Regression guard: the old 1-based insert-BEFORE landed this one line early. + expect(stored).toBe("line1\nline2\nafter2\nline3\nline4\nline5") + }) + + it("insert_line: n_lines appends at the end of the file", async () => { + const result = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 5, + insert_text: "tail", + }) + + expect(result.success).toBe(true) + const stored = addMock.mock.calls[0]?.[0]?.content as string + expect(stored).toBe("line1\nline2\nline3\nline4\nline5\ntail") + }) + + it("rejects insert_line outside [0, n_lines] without writing", async () => { + const below = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: -1, + insert_text: "x", + }) + expect(below.success).toBe(false) + expect(below.error).toContain("[0, 5]") + + const above = await tool.handleCommand({ + command: "insert", + path: FILE_PATH, + insert_line: 6, + insert_text: "x", + }) + expect(above.success).toBe(false) + expect(addMock).not.toHaveBeenCalled() + }) +}) + describe("ClaudeMemoryTool str_replace replacement literalness", () => { let tool: ClaudeMemoryTool @@ -210,3 +287,165 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => { expect(stored).toContain(`price is ${dollarSequence} today`) }) }) + +describe("ClaudeMemoryTool path traversal", () => { + let tool: ClaudeMemoryTool + + beforeEach(() => { + documentsListMock.mockReset() + documentsGetMock.mockReset() + addMock.mockReset() + mockDocument(FILE_CONTENT) + tool = new ClaudeMemoryTool("test-api-key") + }) + + it.each([ + "/memories/..", + "/memories/foo/..", + "/memories/../secrets.txt", + ])("rejects parent-directory path %s", async (path) => { + const result = await tool.handleCommand({ + command: "view", + path, + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") + expect(documentsListMock).not.toHaveBeenCalled() + }) +}) + +describe("ClaudeMemoryTool path normalization collision resistance", () => { + beforeEach(() => { + documentsListMock.mockReset() + documentsGetMock.mockReset() + addMock.mockReset() + documentsDeleteBulkMock.mockReset() + }) + + it("produces distinct customIds for paths that previously collided", () => { + const tool = new ClaudeMemoryTool("test-api-key") + const paths = [ + "/memories/notes.txt", + "/memories/notes_txt", + "/memories/notes/txt", + "/memories/project/a.md", + "/memories/project_a.md", + ] + + const ids = paths.map((path) => tool.normalizePathToCustomId(path)) + const uniqueIds = new Set(ids) + + expect(uniqueIds.size).toBe(paths.length) + }) + + it("resolves documents stored under legacy customId format", async () => { + // Mock a document saved with legacy normalization (memories_notes_txt) + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content", + }, + ]) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "view", + path: "/memories/notes.txt", + }) + + expect(result.success).toBe(true) + expect(result.content).toContain("legacy content") + }) + + it("cleans up legacy-customId document when str_replace updates the file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content hello", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "str_replace", + path: "/memories/notes.txt", + old_str: "hello", + new_str: "world", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) + + it("cleans up legacy-customId document when insert updates the file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "line1\nline2", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "insert", + path: "/memories/notes.txt", + insert_line: 2, + insert_text: "inserted line", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) + + it("cleans up legacy-customId document when create overwrites an existing file", async () => { + mockDocuments([ + { + id: "legacy-doc", + customId: "memories_notes_txt", + filePath: "/memories/notes.txt", + content: "legacy content", + }, + ]) + documentsDeleteBulkMock.mockResolvedValue({ + success: true, + deletedCount: 1, + }) + + const tool = new ClaudeMemoryTool("test-api-key") + const result = await tool.handleCommand({ + command: "create", + path: "/memories/notes.txt", + file_text: "brand new content", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledTimes(1) + expect(addMock.mock.calls[0]?.[0]?.customId).toBe("memories_s_notes_d_txt") + expect(documentsDeleteBulkMock).toHaveBeenCalledWith({ + ids: ["legacy-doc"], + }) + }) +}) diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts index 867c4d82..bd169879 100644 --- a/packages/tools/src/claude-memory.ts +++ b/packages/tools/src/claude-memory.ts @@ -9,7 +9,8 @@ export interface ClaudeMemoryConfig extends SupermemoryToolsConfig { export interface MemoryCommand { command: "view" | "create" | "str_replace" | "insert" | "delete" | "rename" - path: string + // every command except rename addresses the file via path + path?: string // view specific view_range?: [number, number] // create specific @@ -20,7 +21,9 @@ export interface MemoryCommand { // insert specific insert_line?: number insert_text?: string - // rename specific + // rename specific: Claude sends old_path/new_path (path is accepted too + // for backwards compatibility with earlier callers) + old_path?: string new_path?: string } @@ -41,6 +44,7 @@ type ClaudeFileMetadata = Record interface ClaudeFileDocument { documentId: string + customId?: string content: string metadata: ClaudeFileMetadata } @@ -57,13 +61,22 @@ export class ClaudeMemoryTool { /** * Normalize file path to be used as customId - * Converts /memories/file.txt -> memories_file_txt + * Reversibly encodes path components to prevent collisions between paths like + * `/memories/notes.txt`, `/memories/notes_txt`, and `/memories/notes/txt`. */ - private normalizePathToCustomId(path: string): string { + normalizePathToCustomId(path: string): string { return path .replace(/^\//, "") // Remove leading slash - .replace(/\//g, "_") // Replace / with _ - .replace(/\./g, "_") // Replace . with _ + .replace(/_/g, "__") // Escape underscores: _ -> __ + .replace(/\//g, "_s_") // Encode slashes: / -> _s_ + .replace(/\./g, "_d_") // Encode dots: . -> _d_ + } + + /** + * Legacy normalization used in older versions (/ and . both flattened to _) + */ + private legacyNormalizePathToCustomId(path: string): string { + return path.replace(/^\//, "").replace(/\//g, "_").replace(/\./g, "_") } constructor(apiKey: string, config?: ClaudeMemoryConfig) { @@ -86,17 +99,24 @@ export class ClaudeMemoryTool { */ async handleCommand(command: MemoryCommand): Promise { try { + // rename is the one command that doesn't use `path`: Claude sends + // old_path/new_path. Fall back to `path` so older callers keep working. + const path = + command.command === "rename" + ? (command.old_path ?? command.path) + : command.path + // Validate path security - if (!this.isValidPath(command.path)) { + if (path === undefined || !this.isValidPath(path)) { return { success: false, - error: `Invalid path: ${command.path}. All paths must start with /memories/`, + error: `Invalid path: ${path}. All paths must start with /memories/`, } } switch (command.command) { case "view": - return await this.view(command.path, command.view_range) + return await this.view(path, command.view_range) case "create": if (!command.file_text) { return { @@ -104,22 +124,16 @@ export class ClaudeMemoryTool { error: "file_text is required for create command", } } - return await this.create(command.path, command.file_text) + return await this.create(path, command.file_text) case "str_replace": - // 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. + // new_str may be "" (deleting text) but must be present. if (!command.old_str || command.new_str === undefined) { return { success: false, error: "old_str and new_str are required for str_replace command", } } - return await this.strReplace( - command.path, - command.old_str, - command.new_str, - ) + return await this.strReplace(path, command.old_str, command.new_str) case "insert": // insert_text may be "" (inserting a blank line). if ( @@ -133,12 +147,12 @@ export class ClaudeMemoryTool { } } return await this.insert( - command.path, + path, command.insert_line, command.insert_text, ) case "delete": - return await this.delete(command.path) + return await this.delete(path) case "rename": if (!command.new_path) { return { @@ -146,7 +160,7 @@ export class ClaudeMemoryTool { error: "new_path is required for rename command", } } - return await this.rename(command.path, command.new_path) + return await this.rename(path, command.new_path) default: return { success: false, @@ -384,6 +398,8 @@ export class ClaudeMemoryTool { fileText: string, ): Promise { try { + const existing = await this.getFileDocument(filePath) + const normalizedId = this.normalizePathToCustomId(filePath) const _response = await this.client.add({ @@ -399,6 +415,17 @@ export class ClaudeMemoryTool { }, }) + // If an existing document was stored under a legacy customId, clean it up + // so the file path does not collide or become ambiguous with multiple documents. + if ( + existing.success && + existing.document && + existing.document.customId && + existing.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, existing.document.documentId) + } + return { success: true, content: `File created: ${filePath}`, @@ -457,6 +484,15 @@ export class ClaudeMemoryTool { }, }) + // If the modified file was stored under a legacy customId, clean up the legacy + // document to prevent path ambiguity. + if ( + readResult.document.customId && + readResult.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, readResult.document.documentId) + } + return { success: true, content: `String replaced in file: ${filePath}`, @@ -490,16 +526,16 @@ export class ClaudeMemoryTool { const originalContent = readResult.document.content const lines = originalContent.split("\n") - // Validate line number - if (insertLine < 1 || insertLine > lines.length + 1) { + // insert_line is the line the text goes after: 0 means the beginning + // of the file and lines.length appends at the end. + if (insertLine < 0 || insertLine > lines.length) { return { success: false, - error: `Invalid line number: ${insertLine}. File has ${lines.length} lines.`, + error: `Invalid insert_line parameter: ${insertLine}. It should be within the range of lines of the file: [0, ${lines.length}]`, } } - // Insert the text (insertLine is 1-based) - lines.splice(insertLine - 1, 0, insertText) + lines.splice(insertLine, 0, insertText) const newContent = lines.join("\n") // Update the document @@ -515,9 +551,18 @@ export class ClaudeMemoryTool { }, }) + // If the modified file was stored under a legacy customId, clean up the legacy + // document to prevent path ambiguity. + if ( + readResult.document.customId && + readResult.document.customId !== normalizedId + ) { + await deleteDocumentById(this.client, readResult.document.documentId) + } + return { success: true, - content: `Text inserted at line ${insertLine} in file: ${filePath}`, + content: `Text inserted after line ${insertLine} in file: ${filePath}`, } } catch (error) { return { @@ -650,8 +695,12 @@ export class ClaudeMemoryTool { }) for (const document of response.memories) { + const isMatchingCustomId = + document.customId === normalizedId || + document.customId === this.legacyNormalizePathToCustomId(filePath) + if ( - document.customId === normalizedId && + isMatchingCustomId && this.getDocumentFilePath(document) === filePath && this.isDocumentInConfiguredScope(document) ) { @@ -681,8 +730,12 @@ export class ClaudeMemoryTool { hasUnverifiedCandidate = true continue } + const isMatchingCustomId = + document.customId === normalizedId || + document.customId === this.legacyNormalizePathToCustomId(filePath) + if ( - document.customId !== normalizedId || + !isMatchingCustomId || this.getDocumentFilePath(document) !== filePath || !this.hasExactContainerTags(document.containerTags) ) { @@ -737,7 +790,12 @@ export class ClaudeMemoryTool { return { success: true, - document: { documentId: candidate.id, content, metadata }, + document: { + documentId: candidate.id, + customId: document.customId ?? candidate.customId ?? undefined, + content, + metadata, + }, } } catch (error) { return { @@ -805,11 +863,15 @@ export class ClaudeMemoryTool { * Validate that path starts with /memories for security */ private isValidPath(path: string): boolean { - return ( - (path.startsWith("/memories/") || path === "/memories") && - !path.includes("../") && - !path.includes("..\\") - ) + if (!(path.startsWith("/memories/") || path === "/memories")) { + return false + } + if (path.includes("..\\")) { + return false + } + // Reject any parent-directory segment, including trailing "/.." which + // the previous "../" substring check missed (e.g. "/memories/.."). + return !path.split("/").some((segment) => segment === "..") } } diff --git a/packages/tools/src/conversations-client.ts b/packages/tools/src/conversations-client.ts index 4a458e3a..981d0bf6 100644 --- a/packages/tools/src/conversations-client.ts +++ b/packages/tools/src/conversations-client.ts @@ -6,6 +6,8 @@ * diffing and append detection on the backend. */ +import { normalizeBaseUrl } from "./shared/context" + export interface ConversationMessage { role: "user" | "assistant" | "system" | "tool" content: string | ContentPart[] @@ -117,7 +119,7 @@ const CONVERSATION_REQUEST_TIMEOUT_MS = 30_000 export async function addConversation( params: AddConversationParams, ): Promise { - const baseUrl = params.baseUrl || "https://api.supermemory.ai" + const baseUrl = normalizeBaseUrl(params.baseUrl) const url = `${baseUrl}/v4/conversations` const response = await fetch(url, { diff --git a/packages/tools/src/openai/index.ts b/packages/tools/src/openai/index.ts index 0567f197..d56c136f 100644 --- a/packages/tools/src/openai/index.ts +++ b/packages/tools/src/openai/index.ts @@ -23,7 +23,7 @@ import { * @param options.addMemory - Optional mode for memory addition: "always" (default), "never" * @param options.apiKey - Optional Supermemory API key; falls back to SUPERMEMORY_API_KEY * - * @returns An OpenAI client with SuperMemory middleware injected for both Chat Completions and Responses APIs + * @returns A distinct OpenAI middleware facade for Chat Completions and Responses APIs. The supplied client is not mutated. * * @example * ```typescript diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 518db547..192f27cf 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -16,6 +16,34 @@ import { deduplicateMemoriesForMode } from "../tools-shared" import { createLogger, type Logger } from "../vercel/logger" import { convertProfileToMarkdown } from "../vercel/util" +// Keep canonicalization stable across duplicate package copies and hot reloads. +const BASE_CLIENT_SYMBOL = Symbol.for("@supermemory/tools/openai/base-client") +const baseClientByWrapper = new WeakMap() + +const getBaseClient = (client: OpenAI) => + (Reflect.get(client, BASE_CLIENT_SYMBOL) as OpenAI | undefined) ?? + baseClientByWrapper.get(client) ?? + client + +const cloneWithOverrides = ( + source: T, + overrides: Partial, +): T => { + const descriptors = Object.getOwnPropertyDescriptors(source) + + for (const key of Reflect.ownKeys(overrides) as Array) { + const current = Object.getOwnPropertyDescriptor(source, key) + Reflect.set(descriptors, key, { + configurable: current?.configurable ?? true, + enumerable: current?.enumerable ?? false, + value: overrides[key], + writable: current && "writable" in current ? current.writable : true, + }) + } + + return Object.create(Object.getPrototypeOf(source), descriptors) as T +} + const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" return url?.trim().replace(/\/+$/, "") || defaultUrl @@ -761,6 +789,11 @@ export function createOpenAIMiddleware( containerTag: string, options?: OpenAIMiddlewareOptions, ) { + const baseClient = getBaseClient(openaiClient) + const baseChat = baseClient.chat + const baseCompletions = baseChat.completions + const baseResponses = baseClient.responses + const logger = createLogger(options?.verbose ?? false) const apiKey = options?.apiKey?.trim() || process.env.SUPERMEMORY_API_KEY?.trim() || "" @@ -779,8 +812,8 @@ export function createOpenAIMiddleware( const mode = options?.mode ?? "profile" const addMemory = options?.addMemory ?? "always" - const originalCreate = openaiClient.chat.completions.create - const originalResponsesCreate = openaiClient.responses?.create + const originalCreate = baseCompletions.create + const originalResponsesCreate = baseResponses?.create /** * Searches for memories and formats them for injection into API calls. @@ -920,7 +953,7 @@ export function createOpenAIMiddleware( } return { request: originalResponsesCreate.call( - openaiClient.responses, + baseResponses, cleanedParams, requestOptions, ), @@ -972,7 +1005,7 @@ export function createOpenAIMiddleware( return { request: originalResponsesCreate.call( - openaiClient.responses, + baseResponses, { ...params, input: cleanedInput, @@ -1023,7 +1056,7 @@ export function createOpenAIMiddleware( logger.debug("No textual user message found, skipping memory search") return { request: originalCreate.call( - openaiClient.chat.completions, + baseCompletions, { ...params, messages: updateChatMemoryContexts(messages), @@ -1078,7 +1111,7 @@ export function createOpenAIMiddleware( return { request: originalCreate.call( - openaiClient.chat.completions, + baseCompletions, { ...params, messages: enhancedMessages, @@ -1093,14 +1126,26 @@ export function createOpenAIMiddleware( requestOptions?: OpenAI.RequestOptions, ) => deferAPIPromise(() => prepareCreateWithMemory(params, requestOptions)) - openaiClient.chat.completions.create = - createWithMemory as typeof originalCreate + const wrappedCompletions = cloneWithOverrides(baseCompletions, { + create: createWithMemory as typeof originalCreate, + }) + const wrappedChat = cloneWithOverrides(baseChat, { + completions: wrappedCompletions, + }) + const wrappedResponses = + baseResponses && originalResponsesCreate + ? cloneWithOverrides(baseResponses, { + create: createResponsesWithMemory as typeof originalResponsesCreate, + }) + : undefined + const wrappedClient = cloneWithOverrides(baseClient, { + chat: wrappedChat, + ...(wrappedResponses ? { responses: wrappedResponses } : {}), + }) - // Wrap Responses API if available - if (originalResponsesCreate) { - openaiClient.responses.create = - createResponsesWithMemory as typeof originalResponsesCreate - } - - return openaiClient + Object.defineProperty(wrappedClient, BASE_CLIENT_SYMBOL, { + value: baseClient, + }) + baseClientByWrapper.set(wrappedClient, baseClient) + return wrappedClient } diff --git a/packages/tools/src/openai/tools.ts b/packages/tools/src/openai/tools.ts index 1be29084..c0371fa9 100644 --- a/packages/tools/src/openai/tools.ts +++ b/packages/tools/src/openai/tools.ts @@ -9,6 +9,7 @@ import { clampSearchLimit, deleteDocumentByIdentifier, getContainerTags, + resolveConfiguredContainerTag, } from "../tools-shared" import { forgetMemoryRequest } from "../shared/forget-memory" import type { SupermemoryToolsConfig } from "../types" @@ -333,7 +334,7 @@ export function createGetProfileFunction( query?: string }): Promise { try { - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) const response = await client.profile({ containerTag: tag, @@ -374,7 +375,7 @@ export function createDocumentListFunction( }): Promise { try { const scopeTags: [string, ...string[]] = containerTag - ? [containerTag] + ? [resolveConfiguredContainerTag(containerTags, containerTag)] : containerTags const response = await client.documents.list({ @@ -502,12 +503,12 @@ export function createMemoryForgetFunction( } } - const tag = containerTag || containerTags[0] + const tag = resolveConfiguredContainerTag(containerTags, containerTag) await forgetMemoryRequest( apiKey, { - containerTag: tag as string, + containerTag: tag, ...(memoryId && { id: memoryId }), ...(memoryContent && { content: memoryContent }), ...(reason && { reason }), @@ -570,8 +571,21 @@ export function getToolDefinitions(): OpenAI.Chat.Completions.ChatCompletionTool } function parseToolArguments(argumentsJson: string) { + // getProfile, documentList and memoryForget all declare `required: []`, so a model + // may legitimately call them with no arguments. OpenAI serialises that as `""`, + // which is "no arguments" rather than malformed JSON — parse it as `{}`. + const source = argumentsJson?.trim() || "{}" + try { - return { success: true as const, value: JSON.parse(argumentsJson) } + const value = JSON.parse(source) + + // `"null"`, `"5"` and `"[]"` parse cleanly, then throw in the destructuring + // parameter of every tool function — the throw this gate exists to contain. + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return { success: false as const } + } + + return { success: true as const, value } } catch { return { success: false as const } } diff --git a/packages/tools/src/shared/cache.ts b/packages/tools/src/shared/cache.ts index 15ce16fe..0fe1beed 100644 --- a/packages/tools/src/shared/cache.ts +++ b/packages/tools/src/shared/cache.ts @@ -5,6 +5,9 @@ import type { MemoryMode } from "./types" * Generic memory cache for storing per-turn memories to avoid redundant API calls. * Used to cache memory retrieval results during tool-call loops within the same turn. */ +const escapeKeySegment = (segment: string): string => + segment.replace(/%/g, "%25").replace(/:/g, "%3A") + export class MemoryCache { private cache: LRUCache = new LRUCache({ max: 100 }) @@ -25,7 +28,9 @@ export class MemoryCache { message: string, ): string { const normalizedMessage = message.trim().replace(/\s+/g, " ") - return `${containerTag}:${threadId || ""}:${mode}:${normalizedMessage}` + const safeContainerTag = escapeKeySegment(containerTag) + const safeThreadId = escapeKeySegment(threadId ?? "") + return `${safeContainerTag}:${safeThreadId}:${mode}:${normalizedMessage}` } /** diff --git a/packages/tools/src/shared/context.ts b/packages/tools/src/shared/context.ts index b4bf3eb1..ff3035a2 100644 --- a/packages/tools/src/shared/context.ts +++ b/packages/tools/src/shared/context.ts @@ -8,8 +8,8 @@ import Supermemory from "supermemory" */ export const normalizeBaseUrl = (url?: string): string => { const defaultUrl = "https://api.supermemory.ai" - if (!url) return defaultUrl - return url.endsWith("/") ? url.slice(0, -1) : url + const trimmed = url?.trim().replace(/\/+$/, "") + return trimmed || defaultUrl } /** diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts index 8691c92a..97b7e510 100644 --- a/packages/tools/src/shared/forget-memory.ts +++ b/packages/tools/src/shared/forget-memory.ts @@ -33,7 +33,11 @@ export async function forgetMemoryRequest( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(params), - signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS), + // Compose rather than choose: a caller-supplied signal must add cancellation + // on top of the timeout, not replace it, or the request becomes unbounded. + signal: options?.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/shared/memory-client.test.ts b/packages/tools/src/shared/memory-client.test.ts index 4b4edc0a..dee35699 100644 --- a/packages/tools/src/shared/memory-client.test.ts +++ b/packages/tools/src/shared/memory-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { buildMemoriesText } from "./memory-client" +import { buildMemoriesText, supermemoryProfileSearch } from "./memory-client" import { createLogger } from "./logger" const API_KEY = "sm_test_key" @@ -76,3 +76,72 @@ describe("buildMemoriesText", () => { expect(memories.match(/User is allergic to peanuts/g)).toHaveLength(1) }) }) + +describe("supermemoryProfileSearch request hardening", () => { + /** Captures the `fetch` init so the request options can be asserted. */ + function captureRequestInit() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ profile: { static: [], dynamic: [] } }), + }) + vi.stubGlobal("fetch", fetchMock) + return () => fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + } + + // The request carries the API key in an Authorization header. Following a + // redirect would replay it against a host the caller never configured. + it("refuses to follow redirects", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch(CONTAINER_TAG, "", BASE_URL, API_KEY) + + expect(getInit()?.redirect).toBe("error") + }) + + // Mastra, VoltAgent and the exported helpers call this with no signal, so + // without an unconditional timeout a hung socket blocks the turn forever. + it("bounds the request even when the caller passes no signal", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch(CONTAINER_TAG, "", BASE_URL, API_KEY) + + const signal = getInit()?.signal + expect(signal).toBeInstanceOf(AbortSignal) + expect(signal?.aborted).toBe(false) + }) + + // The caller signal is composed with the timeout rather than replacing it, + // so a caller-side budget still shortens the request. + it("still aborts when the caller's signal fires", async () => { + const getInit = captureRequestInit() + const controller = new AbortController() + + await supermemoryProfileSearch( + CONTAINER_TAG, + "", + BASE_URL, + API_KEY, + controller.signal, + ) + + const signal = getInit()?.signal + expect(signal?.aborted).toBe(false) + controller.abort(new Error("caller budget exhausted")) + expect(signal?.aborted).toBe(true) + expect((signal?.reason as Error).message).toBe("caller budget exhausted") + }) + + it("passes an already-aborted caller signal straight through", async () => { + const getInit = captureRequestInit() + + await supermemoryProfileSearch( + CONTAINER_TAG, + "", + BASE_URL, + API_KEY, + AbortSignal.abort(new Error("already cancelled")), + ) + + expect(getInit()?.signal?.aborted).toBe(true) + }) +}) diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 5097f7a1..f8c688ba 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -15,6 +15,28 @@ import { defaultPromptTemplate, } from "./prompt-builder" +/** + * Upper bound for a single `/v4/profile` request, matching the budget the other + * Supermemory calls in this package already use (`/v4/conversations` and + * `/v4/memories`). Without it a stalled connection has no deadline at all: the + * Mastra and VoltAgent integrations, and the exported `buildMemoriesText` / + * `addSystemPrompt` helpers, call this function with no signal of their own, so + * a hung socket would block the agent turn forever. + */ +export const PROFILE_REQUEST_TIMEOUT_MS = 30_000 + +/** + * Bound a request by the package timeout, keeping any caller signal live. + * + * The caller signal is composed with the timeout rather than replacing it, so + * a caller-supplied deadline can only ever shorten the request, never remove + * its upper bound. + */ +const withRequestTimeout = (signal?: AbortSignal): AbortSignal => { + const timeout = AbortSignal.timeout(PROFILE_REQUEST_TIMEOUT_MS) + return signal ? AbortSignal.any([signal, timeout]) : timeout +} + /** * Fetches profile and search results from the Supermemory API. * @@ -22,7 +44,9 @@ import { * @param queryText - Optional query text for semantic search * @param baseUrl - The API base URL * @param apiKey - The API key for authentication - * @param signal - Optional AbortSignal to cancel the request (e.g. retrieval timeout) + * @param signal - Optional AbortSignal to cancel the request early (e.g. a + * caller-side retrieval budget). It is composed with the package timeout, so + * it can shorten the request but never leaves it unbounded. * @returns The profile structure with static, dynamic, and search results */ export const supermemoryProfileSearch = async ( @@ -51,7 +75,10 @@ export const supermemoryProfileSearch = async ( Authorization: `Bearer ${apiKey}`, }, body: payload, - ...(signal ? { signal } : {}), + // The request carries the API key in an Authorization header, so a + // redirect is refused rather than followed to an unverified host. + redirect: "error", + signal: withRequestTimeout(signal), }) if (!response.ok) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 8efef91d..7723b8d2 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const documentsDeleteBulk = vi.fn() const documentsGet = vi.fn() const documentsList = vi.fn() +const profileRequest = vi.fn() const clientAdd = vi.fn() const clientSearch = vi.fn() const clientOptions: unknown[] = [] @@ -15,6 +16,7 @@ vi.mock("supermemory", () => { constructor(options: unknown) { clientOptions.push(options) } + profile = profileRequest add = clientAdd search = clientSearch documents = { @@ -34,9 +36,15 @@ import * as openAi from "./openai/tools" const API_KEY = "sm_test_key" -type ToolWithExecute = { execute: (args: Record) => unknown } +type ToolExecutionResult = { success: boolean; error?: string } +type ToolWithExecute = { + execute: (args: Record) => Promise +} -function executeTool(tool: unknown, args: Record) { +function executeTool( + tool: unknown, + args: Record, +): Promise { return (tool as ToolWithExecute).execute(args) } @@ -55,6 +63,10 @@ beforeEach(() => { memories: [{ id: "doc_1", title: "Doc one" }], pagination: { currentPage: 1, totalItems: 1, totalPages: 1 }, }) + profileRequest.mockReset().mockResolvedValue({ + profile: { static: [], dynamic: [] }, + searchResults: { results: [] }, + }) clientAdd.mockReset().mockResolvedValue({ id: "doc_new" }) clientSearch.mockReset().mockResolvedValue({ results: [] }) clientOptions.length = 0 @@ -112,12 +124,96 @@ describe("searchMemories", () => { }) }) +describe("configured container scope", () => { + it("rejects out-of-scope tags across both tool surfaces before I/O", async () => { + const config = { containerTags: ["tenant-a"] } + const fetchMock = vi.fn() + vi.stubGlobal("fetch", fetchMock) + + const results: ToolExecutionResult[] = await Promise.all([ + executeTool(aiSdk.getProfileTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createGetProfileFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.documentListTool(API_KEY, config), { + containerTag: "tenant-b", + }), + openAi.createDocumentListFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + }), + executeTool(aiSdk.memoryForgetTool(API_KEY, config), { + containerTag: "tenant-b", + memoryId: "mem_1", + }), + openAi.createMemoryForgetFunction( + API_KEY, + config, + )({ + containerTag: "tenant-b", + memoryId: "mem_1", + }), + ]) + + expect(results).toHaveLength(6) + for (const result of results) { + expect(result.success).toBe(false) + expect(result.error).toContain("outside the configured scope") + } + expect(profileRequest).not.toHaveBeenCalled() + expect(documentsList).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("allows selecting another explicitly configured tag", async () => { + const getProfile = openAi.createGetProfileFunction(API_KEY, { + containerTags: ["tenant-a", "tenant-b"], + }) + + const result = await getProfile({ containerTag: "tenant-b" }) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledWith({ + containerTag: "tenant-b", + }) + }) + + it("does not let model input override implicit or project scopes", async () => { + const implicitResult = await openAi.createDocumentListFunction(API_KEY)({ + containerTag: "tenant-b", + }) + const projectResult = await executeTool( + aiSdk.getProfileTool(API_KEY, { projectId: "alpha" }), + { containerTag: "tenant-b" }, + ) + + expect(implicitResult.success).toBe(false) + expect(implicitResult.error).toContain("outside the configured scope") + expect(projectResult.success).toBe(false) + expect(projectResult.error).toContain("outside the configured scope") + expect(documentsList).not.toHaveBeenCalled() + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("fails closed when the configured scope is empty", () => { + expect(() => + openAi.createGetProfileFunction(API_KEY, { containerTags: [] }), + ).toThrow("at least one non-empty containerTag") + expect(profileRequest).not.toHaveBeenCalled() + }) +}) + 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 - } + const result = await executeTool(tool, { documentId: "doc_123" }) expect(result.success).toBe(true) expect(documentsGet).toHaveBeenCalledWith("doc_123") @@ -180,7 +276,7 @@ describe("memoryForget", () => { expect(init.signal).toBeInstanceOf(AbortSignal) }) - it("uses a caller-provided signal instead of creating a timeout", async () => { + it("cancels through a caller-provided signal", async () => { const fetchMock = stubFetch() const controller = new AbortController() @@ -192,7 +288,39 @@ describe("memoryForget", () => { ) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(init.signal).toBe(controller.signal) + // The request signal is a composite, not the caller's own, but aborting + // the caller still aborts the request. + expect(init.signal).not.toBe(controller.signal) + controller.abort() + expect(init.signal?.aborted).toBe(true) + }) + + it("keeps the timeout when a caller-provided signal is present", async () => { + const timeoutController = new AbortController() + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(timeoutController.signal) + const fetchMock = stubFetch() + const controller = new AbortController() + + try { + await forgetMemoryRequest( + API_KEY, + { containerTag: "user_1", id: "mem_1" }, + undefined, + { signal: controller.signal }, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + // Firing only the timeout leg aborts the request: a caller signal adds + // cancellation, it does not remove the 30s bound. + timeoutController.abort() + expect(init.signal?.aborted).toBe(true) + } finally { + timeoutSpy.mockRestore() + } }) it("throws a descriptive error on non-2xx responses", async () => { @@ -209,9 +337,9 @@ describe("memoryForget", () => { containerTags: ["user_2"], }) - const result = (await executeTool(tool, { + 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] @@ -242,6 +370,87 @@ describe("memoryForget", () => { }) }) +describe("openai executeToolCall argument parsing", () => { + type ExecutorToolCall = Parameters< + ReturnType + >[0] + + function toolCall(name: string, args: string) { + return { + id: "call_1", + type: "function", + function: { name, arguments: args }, + } as ExecutorToolCall + } + + // getProfile, documentList and memoryForget declare `required: []`, so the model + // is allowed to call them with no arguments. OpenAI serialises that as "". + it.each([ + "", + " ", + ])("runs a zero-argument tool when arguments are %p", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY, { + containerTags: ["user_1"], + }) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(true) + expect(profileRequest).toHaveBeenCalledTimes(1) + expect(profileRequest).toHaveBeenCalledWith({ containerTag: "user_1" }) + }) + + // These parse cleanly, so the JSON guard lets them through to a destructuring + // parameter that rejects — the throw the guard exists to contain. + it.each([ + "null", + "5", + "[]", + '"text"', + ])("rejects non-object arguments %p as a tool result rather than throwing", async (args) => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse(await execute(toolCall("getProfile", args))) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for getProfile/) + expect(profileRequest).not.toHaveBeenCalled() + }) + + it("still reports malformed JSON as a tool error", async () => { + const execute = openAi.createToolCallExecutor(API_KEY) + + const result = JSON.parse( + await execute(toolCall("searchMemories", "{not json")), + ) + + expect(result.success).toBe(false) + expect(result.error).toMatch(/Invalid JSON arguments for searchMemories/) + expect(clientSearch).not.toHaveBeenCalled() + }) + + it("still passes well-formed arguments through", async () => { + clientSearch.mockResolvedValue({ results: [{ id: "mem_1" }] }) + const execute = openAi.createToolCallExecutor(API_KEY, { + containerTags: ["user_1"], + }) + + const result = JSON.parse( + await execute( + toolCall( + "searchMemories", + JSON.stringify({ informationToGet: "tea", limit: 3 }), + ), + ), + ) + + expect(result.success).toBe(true) + expect(clientSearch).toHaveBeenCalledWith( + expect.objectContaining({ q: "tea", limit: 3 }), + ) + }) +}) + describe("ClaudeMemoryTool", () => { const FILE_PATH = "/memories/prefs.txt" const CUSTOM_ID = "memories_prefs_txt" @@ -309,7 +518,7 @@ describe("ClaudeMemoryTool", () => { const result = await tool.handleCommand({ command: "insert", path: FILE_PATH, - insert_line: 2, + insert_line: 1, insert_text: "", }) @@ -344,7 +553,9 @@ describe("ClaudeMemoryTool", () => { expect(result.success).toBe(true) expect(clientAdd).toHaveBeenCalledWith( - expect.objectContaining({ customId: "memories_renamed_txt" }), + expect.objectContaining({ + customId: tool.normalizePathToCustomId("/memories/renamed.txt"), + }), ) expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] }) }) diff --git a/packages/tools/src/tools-shared.test.ts b/packages/tools/src/tools-shared.test.ts index 3cc070ba..81aab493 100644 --- a/packages/tools/src/tools-shared.test.ts +++ b/packages/tools/src/tools-shared.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from "vitest" +import { makeTurnKey } from "./shared/cache" +import { normalizeBaseUrl } from "./shared/context" import { DEFAULT_VALUES, SEARCH_LIMIT_BOUNDS, clampSearchLimit, deduplicateMemoriesForMode, getContainerTags, + resolveConfiguredContainerTag, } from "./tools-shared" describe("clampSearchLimit", () => { @@ -58,6 +61,32 @@ describe("getContainerTags", () => { }) }) +describe("resolveConfiguredContainerTag", () => { + it("defaults to the first configured tag", () => { + expect(resolveConfiguredContainerTag(["tenant-a", "tenant-b"])).toBe( + "tenant-a", + ) + }) + + it("allows selection within a multi-tag scope", () => { + expect( + resolveConfiguredContainerTag(["tenant-a", "tenant-b"], "tenant-b"), + ).toBe("tenant-b") + }) + + it("rejects tags outside the configured scope", () => { + expect(() => + resolveConfiguredContainerTag(["tenant-a"], "tenant-b"), + ).toThrow('Container tag "tenant-b" is outside the configured scope') + }) + + it("rejects an empty configured scope", () => { + expect(() => resolveConfiguredContainerTag([])).toThrow( + "require at least one configured container tag", + ) + }) +}) + describe("deduplicateMemoriesForMode", () => { // The profile is not injected in "query" mode, so a memory that is both a // profile fact and a search hit must survive in the search results — @@ -130,3 +159,65 @@ describe("deduplicateMemoriesForMode", () => { expect(deduplicated.searchResults).toEqual([]) }) }) + +describe("makeTurnKey", () => { + it("generates predictable turn key for standard inputs", () => { + expect(makeTurnKey("user-123", "thread-456", "full", "hello world")).toBe( + "user-123:thread-456:full:hello world", + ) + }) + + it("normalizes and collapses whitespace in message", () => { + expect( + makeTurnKey("user-123", "thread-456", "full", " hello world \n "), + ).toBe("user-123:thread-456:full:hello world") + }) + + it("handles undefined threadId cleanly", () => { + expect(makeTurnKey("user-123", undefined, "profile", "test")).toBe( + "user-123::profile:test", + ) + }) + + it("escapes colons to prevent cache key collisions between tag and threadId", () => { + const keyA = makeTurnKey("user:123", "456", "profile", "hi") + const keyB = makeTurnKey("user", "123:456", "profile", "hi") + expect(keyA).toBe("user%3A123:456:profile:hi") + expect(keyB).toBe("user:123%3A456:profile:hi") + expect(keyA).not.toBe(keyB) + }) + + it("escapes percent signs to avoid ambiguity with encoded sequences", () => { + const keyA = makeTurnKey("user%3A123", "456", "profile", "hi") + const keyB = makeTurnKey("user:123", "456", "profile", "hi") + expect(keyA).toBe("user%253A123:456:profile:hi") + expect(keyB).toBe("user%3A123:456:profile:hi") + expect(keyA).not.toBe(keyB) + }) +}) + +describe("normalizeBaseUrl", () => { + it("returns default URL when input is missing or empty", () => { + expect(normalizeBaseUrl()).toBe("https://api.supermemory.ai") + expect(normalizeBaseUrl("")).toBe("https://api.supermemory.ai") + expect(normalizeBaseUrl(" ")).toBe("https://api.supermemory.ai") + }) + + it("collapses single and multiple trailing slashes", () => { + expect(normalizeBaseUrl("http://localhost:6768/")).toBe( + "http://localhost:6768", + ) + expect(normalizeBaseUrl("http://localhost:6768///")).toBe( + "http://localhost:6768", + ) + expect(normalizeBaseUrl("https://api.supermemory.ai/")).toBe( + "https://api.supermemory.ai", + ) + }) + + it("trims whitespace around URLs", () => { + expect(normalizeBaseUrl(" http://localhost:6768/ ")).toBe( + "http://localhost:6768", + ) + }) +}) diff --git a/packages/tools/src/tools-shared.ts b/packages/tools/src/tools-shared.ts index f4d150ca..eca95ce5 100644 --- a/packages/tools/src/tools-shared.ts +++ b/packages/tools/src/tools-shared.ts @@ -287,6 +287,34 @@ function hasCompleteContainerTagScope( ) } +/** + * Resolves a model-supplied container tag without allowing it to escape the + * developer-configured scope. + */ +export function resolveConfiguredContainerTag( + configuredTags: readonly string[], + requestedTag?: string, +): string { + const defaultTag = configuredTags[0] + if (defaultTag === undefined) { + throw new Error( + "Supermemory tools require at least one configured container tag.", + ) + } + + if (requestedTag === undefined) { + return defaultTag + } + + if (!configuredTags.includes(requestedTag)) { + throw new Error( + `Container tag "${requestedTag}" is outside the configured scope.`, + ) + } + + return requestedTag +} + /** * Memory item interface representing a single memory with optional metadata */ diff --git a/packages/tools/test-supermemory.ts b/packages/tools/test-supermemory.ts index b3863c7e..628968fe 100644 --- a/packages/tools/test-supermemory.ts +++ b/packages/tools/test-supermemory.ts @@ -10,7 +10,9 @@ const openai = new OpenAI({ }) // Wrap OpenAI client with supermemory -const openaiWithSupermemory = withSupermemory(openai, "test_user_123", { +const openaiWithSupermemory = withSupermemory(openai, { + containerTag: "test_user_123", + customId: "test_user_123_chat", verbose: true, // Enable logging to see what's happening mode: "full", // Search both profile and query memories addMemory: "always", // Auto-save conversations as memories diff --git a/packages/tools/test/anthropic-example.ts b/packages/tools/test/anthropic-example.ts index 0da95b77..a4d1d9fe 100644 --- a/packages/tools/test/anthropic-example.ts +++ b/packages/tools/test/anthropic-example.ts @@ -5,9 +5,33 @@ */ import Anthropic from "@anthropic-ai/sdk" -import { createClaudeMemoryTool } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" +const MEMORY_COMMANDS: readonly string[] = [ + "view", + "create", + "str_replace", + "insert", + "delete", + "rename", +] + +function isMemoryCommand(input: unknown): input is MemoryCommand { + return ( + typeof input === "object" && + input !== null && + "command" in input && + "path" in input && + typeof input.command === "string" && + MEMORY_COMMANDS.includes(input.command) && + typeof input.path === "string" + ) +} + /** * Handle Claude's memory tool calls using the Anthropic SDK */ @@ -37,7 +61,7 @@ async function chatWithMemoryTool() { }) // Conversation messages - const messages: Anthropic.Messages.MessageParam[] = [ + const messages: Anthropic.Beta.Messages.BetaMessageParam[] = [ { role: "user", content: @@ -45,7 +69,7 @@ async function chatWithMemoryTool() { }, ] - console.log("💬 User:", messages[0].content) + console.log("💬 User:", messages[0]?.content) console.log("\n🔄 Sending to Claude with memory tool...") try { @@ -66,20 +90,25 @@ async function chatWithMemoryTool() { console.log("📥 Claude responded:") // Process the response - const toolResults: Anthropic.Messages.ToolResultBlockParam[] = [] + const toolResults: Anthropic.Beta.Messages.BetaToolResultBlockParam[] = [] for (const block of response.content) { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { + const command = block.input + if (!isMemoryCommand(command)) { + console.log("Skipping unrecognized memory tool input:", command) + continue + } console.log("🔧 Claude is using memory tool:") - console.log(" Command:", block.input.command) - console.log(" Path:", block.input.path) + console.log(" Command:", command.command) + console.log(" Path:", command.path) // Handle the memory tool call - const memoryResult = await memoryTool.handleCommand(block.input as any) + const memoryResult = await memoryTool.handleCommand(command) - const toolResult: Anthropic.Messages.ToolResultBlockParam = { + const toolResult: Anthropic.Beta.Messages.BetaToolResultBlockParam = { type: "tool_result", tool_use_id: block.id, content: memoryResult.success @@ -138,14 +167,17 @@ async function chatWithMemoryTool() { if (block.type === "text") { console.log("💭", block.text) } else if (block.type === "tool_use" && block.name === "memory") { + const command = block.input + if (!isMemoryCommand(command)) { + console.log("Skipping unrecognized memory tool input:", command) + continue + } console.log("🔧 Claude is using memory tool again:") - console.log(" Command:", block.input.command) - console.log(" Path:", block.input.path) + console.log(" Command:", command.command) + console.log(" Path:", command.path) // Handle additional memory tool calls - const memoryResult = await memoryTool.handleCommand( - block.input as any, - ) + const memoryResult = await memoryTool.handleCommand(command) console.log( "📊 Memory operation result:", memoryResult.success ? "✅ Success" : "❌ Failed", @@ -239,7 +271,7 @@ async function testMemoryOperations() { command: { command: "view" as const, path: "/memories/project-notes.txt", - view_range: [4, 8], + view_range: [4, 8] as [number, number], }, }, ] diff --git a/packages/tools/test/claude-memory-commands.test.ts b/packages/tools/test/claude-memory-commands.test.ts new file mode 100644 index 00000000..ef1d92ef --- /dev/null +++ b/packages/tools/test/claude-memory-commands.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { addMock, listMock, getMock, deleteBulkMock } = vi.hoisted(() => ({ + addMock: vi.fn(), + listMock: vi.fn(), + getMock: vi.fn(), + deleteBulkMock: vi.fn(), +})) + +vi.mock("supermemory", () => ({ + default: class MockSupermemory { + add = addMock + memories = { forget: vi.fn() } + documents = { list: listMock, get: getMock, deleteBulk: deleteBulkMock } + }, +})) + +import { createClaudeMemoryTool } from "../src/claude-memory" + +function stubFile(path: string, content: string) { + const customId = createClaudeMemoryTool("k").normalizePathToCustomId(path) + const metadata = { claude_memory_type: "file", file_path: path } + listMock.mockResolvedValue({ + memories: [ + { + id: "doc_src", + customId, + containerTags: ["claude_memory"], + metadata, + }, + ], + pagination: { totalPages: 1 }, + }) + getMock.mockResolvedValue({ + id: "doc_src", + customId, + containerTags: ["sm_project_default", "claude_memory"], + metadata, + content, + }) +} + +describe("ClaudeMemoryTool rename", () => { + let tool: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + addMock.mockResolvedValue({ id: "doc_1" }) + deleteBulkMock.mockResolvedValue({ success: true, deletedCount: 1 }) + listMock.mockResolvedValue({ memories: [], pagination: { totalPages: 1 } }) + tool = createClaudeMemoryTool("test-api-key") + }) + + it("handles the old_path/new_path shape Claude actually sends", async () => { + stubFile("/memories/draft.txt", "file body") + + const result = await tool.handleCommand({ + command: "rename", + old_path: "/memories/draft.txt", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(true) + expect(addMock).toHaveBeenCalledWith( + expect.objectContaining({ content: "file body" }), + ) + }) + + it("still accepts path as the source for older callers", async () => { + stubFile("/memories/draft.txt", "file body") + + const result = await tool.handleCommand({ + command: "rename", + path: "/memories/draft.txt", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(true) + }) + + it("validates old_path like any other path", async () => { + const result = await tool.handleCommand({ + command: "rename", + old_path: "/etc/passwd", + new_path: "/memories/final.txt", + }) + + expect(result.success).toBe(false) + expect(result.error).toContain("Invalid path") + }) +}) diff --git a/packages/tools/test/claude-memory-examples.ts b/packages/tools/test/claude-memory-examples.ts index f37a47fa..4336f1e9 100644 --- a/packages/tools/test/claude-memory-examples.ts +++ b/packages/tools/test/claude-memory-examples.ts @@ -6,7 +6,10 @@ * 2. Anthropic SDK integration */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" // ===================================================== // Example 1: Direct TypeScript/fetch Integration @@ -67,8 +70,7 @@ export async function directFetchExample() { ] // Execute each command - for (let i = 0; i < commands.length; i++) { - const command = commands[i] + for (const [i, command] of commands.entries()) { console.log( `\n📝 Step ${i + 1}: ${command.command.toUpperCase()} ${command.path}`, ) diff --git a/packages/tools/test/claude-memory-real-example.ts b/packages/tools/test/claude-memory-real-example.ts index dbd03b21..1a925669 100644 --- a/packages/tools/test/claude-memory-real-example.ts +++ b/packages/tools/test/claude-memory-real-example.ts @@ -4,7 +4,10 @@ * This shows actual tool call handling based on real Claude API responses */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" // ===================================================== // Real Claude API Integration diff --git a/packages/tools/test/claude-memory.test.ts b/packages/tools/test/claude-memory.test.ts index 97bccd90..f00c9507 100644 --- a/packages/tools/test/claude-memory.test.ts +++ b/packages/tools/test/claude-memory.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach } from "vitest" -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" // Test configuration @@ -10,6 +13,10 @@ const TEST_CONFIG = { memoryContainerTag: "claude_memory_test", } +// Same gate the other integration suites use: these hit the live API, so they +// only run when a key is present. Without one every request comes back 401. +const shouldRunIntegration = !!process.env.SUPERMEMORY_API_KEY + describe("Claude Memory Tool", () => { let memoryTool: ReturnType @@ -62,7 +69,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("File operations", () => { + describe.skipIf(!shouldRunIntegration)("File operations", () => { const testFilePath = "/memories/test-file.txt" const testContent = "Hello, World!\nThis is a test file.\nLine 3 here." @@ -219,7 +226,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("Directory operations", () => { + describe.skipIf(!shouldRunIntegration)("Directory operations", () => { it("should list empty directory", async () => { const result = await memoryTool.handleCommand({ command: "view", @@ -263,7 +270,7 @@ describe("Claude Memory Tool", () => { }) }) - describe("Error handling", () => { + describe.skipIf(!shouldRunIntegration)("Error handling", () => { it("should handle missing file", async () => { const result = await memoryTool.handleCommand({ command: "view", diff --git a/packages/tools/test/mastra/integration.test.ts b/packages/tools/test/mastra/integration.test.ts index cf6402a2..7dda1307 100644 --- a/packages/tools/test/mastra/integration.test.ts +++ b/packages/tools/test/mastra/integration.test.ts @@ -121,6 +121,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -153,6 +154,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -192,6 +194,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -234,6 +237,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -248,6 +252,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -283,6 +288,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -314,6 +320,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -347,6 +354,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -384,6 +392,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -418,6 +427,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await input.processInput(inputArgs) @@ -431,6 +441,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await output.processOutputResult(outputArgs) @@ -471,6 +482,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await inputProcessor.processInput(args) @@ -535,6 +547,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -559,6 +572,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -593,6 +607,7 @@ describe.skipIf(!shouldRunIntegration)( messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -617,6 +632,7 @@ describe.skipIf(!shouldRunIntegration)( messageList: createIntegrationMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await expect(processor.processOutputResult(args)).resolves.toBeDefined() diff --git a/packages/tools/test/mastra/unit.test.ts b/packages/tools/test/mastra/unit.test.ts index e4fd8263..4100fc3c 100644 --- a/packages/tools/test/mastra/unit.test.ts +++ b/packages/tools/test/mastra/unit.test.ts @@ -191,6 +191,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -227,6 +228,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -238,6 +240,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -270,6 +273,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args1) @@ -281,6 +285,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args2) @@ -302,6 +307,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -333,6 +339,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } const result = await processor.processInput(args) @@ -360,6 +367,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -389,6 +397,7 @@ describe("SupermemoryInputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -432,6 +441,7 @@ describe("SupermemoryInputProcessor", () => { messageList, abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processInput(args) @@ -499,6 +509,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -539,6 +550,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -567,6 +579,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -602,6 +615,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, requestContext, } @@ -636,6 +650,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -672,6 +687,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -727,6 +743,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) @@ -760,6 +777,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } // Should not throw @@ -779,6 +797,7 @@ describe("SupermemoryOutputProcessor", () => { messageList: createMockMessageList(), abort: vi.fn() as never, retryCount: 0, + state: {}, } await processor.processOutputResult(args) diff --git a/packages/tools/test/openai-middleware.unit.test.ts b/packages/tools/test/openai-middleware.unit.test.ts index e3249c8d..ce04e1c0 100644 --- a/packages/tools/test/openai-middleware.unit.test.ts +++ b/packages/tools/test/openai-middleware.unit.test.ts @@ -26,7 +26,7 @@ describe("OpenAI middleware memory context", () => { }), }), ) - const originalCreate = vi.fn(() => + const originalCreate = vi.fn((_body?: unknown) => Object.assign(Promise.resolve({ choices: [] }), { asResponse: async () => new Response(), }), @@ -53,8 +53,11 @@ describe("OpenAI middleware memory context", () => { ], }) - const forwarded = originalCreate.mock.calls[0]?.[0] - const content = String(forwarded.messages[0].content) + const forwarded = originalCreate.mock.calls[0]?.[0] as + | { messages: Array<{ role: string; content: unknown }> } + | undefined + expect(forwarded).toBeDefined() + const content = String(forwarded?.messages[0]?.content) expect(content).toContain("Be helpful.") expect(content).toContain("Fresh profile fact") expect(content).not.toContain("Stale profile fact") diff --git a/packages/tools/test/test-memory-tool.ts b/packages/tools/test/test-memory-tool.ts index c0395a79..65bfaa1c 100644 --- a/packages/tools/test/test-memory-tool.ts +++ b/packages/tools/test/test-memory-tool.ts @@ -4,7 +4,10 @@ * Run with: bun run src/test-memory-tool.ts */ -import { createClaudeMemoryTool, type MemoryCommand } from "./claude-memory" +import { + createClaudeMemoryTool, + type MemoryCommand, +} from "../src/claude-memory" import "dotenv/config" async function testMemoryTool() { @@ -140,8 +143,7 @@ async function testMemoryTool() { let passed = 0 let failed = 0 - for (let i = 0; i < testCases.length; i++) { - const testCase = testCases[i] + for (const [i, testCase] of testCases.entries()) { console.log(`\\n🔄 Test ${i + 1}/${testCases.length}: ${testCase.name}`) try { diff --git a/packages/tools/test/voltagent.unit.test.ts b/packages/tools/test/voltagent.unit.test.ts index 559f1fa1..4c9f8a8f 100644 --- a/packages/tools/test/voltagent.unit.test.ts +++ b/packages/tools/test/voltagent.unit.test.ts @@ -36,10 +36,13 @@ describe("VoltAgent memory context", () => { parts: [], }, ], - } as Parameters>[0] + } as unknown as Parameters>[0] const result = await hooks.onPrepareMessages?.(args) - const content = String(result?.messages?.[0]?.content ?? "") + const firstMessage = result?.messages?.[0] as + | Record + | undefined + const content = String(firstMessage?.content ?? "") expect(content).toContain("Be helpful.") expect(content).toContain("Fresh profile fact") expect(content).not.toContain("Stale profile fact") diff --git a/packages/tools/test/with-supermemory/unit.test.ts b/packages/tools/test/with-supermemory/unit.test.ts index 50900a6f..8c3d68af 100644 --- a/packages/tools/test/with-supermemory/unit.test.ts +++ b/packages/tools/test/with-supermemory/unit.test.ts @@ -561,8 +561,8 @@ describe("Unit: withSupermemory", () => { usage: { inputTokens: 1, outputTokens: 1, + totalTokens: 2, }, - rawCall: { rawPrompt: [], rawSettings: {} }, warnings: [], }) @@ -626,8 +626,8 @@ describe("Unit: withSupermemory", () => { usage: { inputTokens: 1, outputTokens: 1, + totalTokens: 2, }, - rawCall: { rawPrompt: [], rawSettings: {} }, warnings: [], }) diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index d40ba072..61e121d9 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -1,5 +1,8 @@ { "extends": "@total-typescript/tsconfig/bundler/dom/library-monorepo", + // test/chatapp is a standalone Next.js demo with its own package.json, + // lockfile and tsconfig; it is not part of this package's program. + "exclude": ["node_modules", "test/chatapp"], "compilerOptions": { "baseUrl": ".", "paths": {