mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-10 03:28:14 +00:00
Merge 9fd2e989bb into ac21804982
This commit is contained in:
commit
36c45090b6
33 changed files with 1172 additions and 118 deletions
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
66
apps/mcp/src/server/client/index.test.ts
Normal file
66
apps/mcp/src/server/client/index.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -339,7 +339,14 @@ export class SupermemoryClient {
|
|||
options?: { signal?: AbortSignal },
|
||||
): Promise<DocumentsApiResponse> {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, string | number | boolean | string[]>
|
|||
|
||||
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<MemoryResponse> {
|
||||
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<MemoryResponse> {
|
||||
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 === "..")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AddConversationResponse> {
|
||||
const baseUrl = params.baseUrl || "https://api.supermemory.ai"
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl)
|
||||
const url = `${baseUrl}/v4/conversations`
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<OpenAI, OpenAI>()
|
||||
|
||||
const getBaseClient = (client: OpenAI) =>
|
||||
(Reflect.get(client, BASE_CLIENT_SYMBOL) as OpenAI | undefined) ??
|
||||
baseClientByWrapper.get(client) ??
|
||||
client
|
||||
|
||||
const cloneWithOverrides = <T extends object>(
|
||||
source: T,
|
||||
overrides: Partial<T>,
|
||||
): T => {
|
||||
const descriptors = Object.getOwnPropertyDescriptors(source)
|
||||
|
||||
for (const key of Reflect.ownKeys(overrides) as Array<keyof T>) {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProfileResult> {
|
||||
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<DocumentListResult> {
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T = string> {
|
||||
private cache: LRUCache<string, T> = new LRUCache({ max: 100 })
|
||||
|
||||
|
|
@ -25,7 +28,9 @@ export class MemoryCache<T = string> {
|
|||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => unknown }
|
||||
type ToolExecutionResult = { success: boolean; error?: string }
|
||||
type ToolWithExecute = {
|
||||
execute: (args: Record<string, unknown>) => Promise<ToolExecutionResult>
|
||||
}
|
||||
|
||||
function executeTool(tool: unknown, args: Record<string, unknown>) {
|
||||
function executeTool(
|
||||
tool: unknown,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<ToolExecutionResult> {
|
||||
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<typeof openAi.createToolCallExecutor>
|
||||
>[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] })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
|||
91
packages/tools/test/claude-memory-commands.test.ts
Normal file
91
packages/tools/test/claude-memory-commands.test.ts
Normal file
|
|
@ -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<typeof createClaudeMemoryTool>
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof createClaudeMemoryTool>
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,13 @@ describe("VoltAgent memory context", () => {
|
|||
parts: [],
|
||||
},
|
||||
],
|
||||
} as Parameters<NonNullable<typeof hooks.onPrepareMessages>>[0]
|
||||
} as unknown as Parameters<NonNullable<typeof hooks.onPrepareMessages>>[0]
|
||||
const result = await hooks.onPrepareMessages?.(args)
|
||||
|
||||
const content = String(result?.messages?.[0]?.content ?? "")
|
||||
const firstMessage = result?.messages?.[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const content = String(firstMessage?.content ?? "")
|
||||
expect(content).toContain("Be helpful.")
|
||||
expect(content).toContain("Fresh profile fact")
|
||||
expect(content).not.toContain("Stale profile fact")
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue