fix(tools): harden seven-tool parity

This commit is contained in:
ved015 2026-08-22 12:13:25 +05:30
parent d04bb3b7b1
commit d21d661380
11 changed files with 614 additions and 193 deletions

View file

@ -29,5 +29,22 @@ jobs:
- name: Run TypeScript type checking
run: bunx turbo run check-types --filter='@supermemory/ai-sdk' --filter='@supermemory/memory-graph'
- name: Detect Tools package changes
id: tools-changes
run: |
if git diff --quiet "${{ github.event.pull_request.base.sha }}" HEAD -- packages/tools; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Run Tools unit tests
if: steps.tools-changes.outputs.changed == 'true'
run: bun run --cwd packages/tools test:unit
- name: Build Tools package
if: steps.tools-changes.outputs.changed == 'true'
run: bun run --cwd packages/tools build
- name: Run Biome CI (format & lint on changed files)
run: bunx biome ci --changed --since=origin/main --no-errors-on-unmatched

View file

@ -336,7 +336,7 @@
},
"packages/tools": {
"name": "@supermemory/tools",
"version": "2.1.1",
"version": "2.2.0",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.25",
"@ai-sdk/openai": "^2.0.23",

View file

@ -1,14 +1,14 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.1.1",
"version": "2.2.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 test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
"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/mastra/unit.test.ts",
"test:watch": "vitest --watch --testTimeout 100000"
},
"dependencies": {

View file

@ -5,6 +5,7 @@ import {
DEFAULT_VALUES,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
deleteDocumentByIdentifier,
getContainerTags,
} from "./tools-shared"
import { forgetMemoryRequest } from "./shared/forget-memory"
@ -56,12 +57,12 @@ export const searchMemoriesTool = (
limit = DEFAULT_VALUES.limit,
}) => {
try {
const response = await client.search({
const response = await client.search.documents({
q: informationToGet,
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
containerTags,
limit,
threshold: DEFAULT_VALUES.chunkThreshold,
searchMode: "hybrid",
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
return {
@ -196,10 +197,12 @@ export const documentListTool = (
}),
execute: async ({ containerTag, limit, page }) => {
try {
const tag = containerTag || containerTags[0]
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
const response = await client.documents.list({
containerTags: [tag],
containerTags: scopeTags,
limit: limit || DEFAULT_VALUES.limit,
...(page !== undefined && { page }),
})
@ -227,15 +230,29 @@ export const documentDeleteTool = (
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
const containerTags = getContainerTags(config)
const strict = config?.strict ?? false
return tool({
description: TOOL_DESCRIPTIONS.documentDelete,
inputSchema: z.object({
documentId: z.string().describe(PARAMETER_DESCRIPTIONS.documentId),
containerTag: strict
? z
.string()
.nullable()
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag)
: z
.string()
.optional()
.describe(PARAMETER_DESCRIPTIONS.documentContainerTag),
}),
execute: async ({ documentId }) => {
execute: async ({ documentId, containerTag }) => {
try {
await client.documents.delete(documentId)
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
await deleteDocumentByIdentifier(client, documentId, scopeTags)
return {
success: true,

View file

@ -1,18 +1,22 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock the Supermemory SDK so the Claude memory tool's `view`/`readFile` path
// can be exercised deterministically without any network access. We only need
// `client.search()` to return a single document with known multi-line content.
const searchMock = vi.fn()
// Mock the Supermemory SDK so the Claude memory tool's document-backed file
// operations can be exercised deterministically without any network access.
const documentsListMock = vi.fn()
const documentsGetMock = vi.fn()
const documentsDeleteBulkMock = vi.fn()
const addMock = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = searchMock
add = addMock
memories = { forget: vi.fn() }
documents = { delete: vi.fn() }
documents = {
list: documentsListMock,
get: documentsGetMock,
deleteBulk: documentsDeleteBulkMock,
}
},
}
})
@ -22,20 +26,58 @@ import { ClaudeMemoryTool } from "./claude-memory"
const FILE_PATH = "/memories/notes.txt"
// 5 distinct lines so an off-by-one at either end is observable.
const FILE_CONTENT = "line1\nline2\nline3\nline4\nline5"
const FILE_DOCUMENT = {
id: "document-notes",
customId: "memories_notes_txt",
filePath: FILE_PATH,
content: FILE_CONTENT,
}
const NEIGHBOUR_DOCUMENT = {
id: "document-notes-backup",
customId: "memories_notes_backup_txt",
filePath: "/memories/notes.backup.txt",
content: "backup stuff",
}
function mockDocuments(documents: typeof FILE_DOCUMENT[]) {
documentsListMock.mockResolvedValue({
memories: documents.map((document) => ({
id: document.id,
customId: document.customId,
containerTags: ["claude_memory"],
metadata: {
claude_memory_type: "file",
file_path: document.filePath,
},
})),
pagination: { totalPages: 1 },
})
documentsGetMock.mockImplementation(async (id: string) => {
const document = documents.find((candidate) => candidate.id === id)
if (!document) throw new Error(`Document not found: ${id}`)
return {
id: document.id,
customId: document.customId,
containerTags: ["sm_project_default", "claude_memory"],
metadata: {
claude_memory_type: "file",
file_path: document.filePath,
},
content: document.content,
}
})
}
function mockDocument(content: string) {
// `readFile` matches by `id === normalizePathToCustomId(path)`.
// normalizePathToCustomId("/memories/notes.txt") -> "memories_notes_txt"
searchMock.mockResolvedValue({
results: [{ id: "memories_notes_txt", chunk: content }],
})
mockDocuments([{ ...FILE_DOCUMENT, content }])
}
describe("ClaudeMemoryTool view_range", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
mockDocument(FILE_CONTENT)
tool = new ClaudeMemoryTool("test-api-key")
})
@ -90,18 +132,14 @@ describe("ClaudeMemoryTool exact-file matching", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
addMock.mockReset()
tool = new ClaudeMemoryTool("test-api-key")
})
it("view finds the exact file even when a neighbour ranks first", async () => {
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
{ id: "memories_notes_txt", chunk: FILE_CONTENT },
],
})
it("view finds the exact file even when a neighbour is listed first", async () => {
mockDocuments([NEIGHBOUR_DOCUMENT, FILE_DOCUMENT])
const result = await tool.handleCommand({
command: "view",
@ -114,13 +152,9 @@ describe("ClaudeMemoryTool exact-file matching", () => {
})
it("view reports not-found instead of returning a different file", async () => {
// Semantic search can surface a similarly-named file; that must not
// The document list can contain a similarly-named file; that must not
// be served as the requested one.
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
],
})
mockDocuments([NEIGHBOUR_DOCUMENT])
const result = await tool.handleCommand({
command: "view",
@ -132,11 +166,7 @@ describe("ClaudeMemoryTool exact-file matching", () => {
})
it("str_replace refuses to modify a different file than requested", async () => {
searchMock.mockResolvedValue({
results: [
{ id: "memories_notes_backup_txt", chunk: "backup stuff" },
],
})
mockDocuments([NEIGHBOUR_DOCUMENT])
const result = await tool.handleCommand({
command: "str_replace",
@ -154,11 +184,10 @@ describe("ClaudeMemoryTool str_replace replacement literalness", () => {
let tool: ClaudeMemoryTool
beforeEach(() => {
searchMock.mockReset()
documentsListMock.mockReset()
documentsGetMock.mockReset()
addMock.mockReset()
searchMock.mockResolvedValue({
results: [{ id: "memories_notes_txt", chunk: FILE_CONTENT }],
})
mockDocument(FILE_CONTENT)
tool = new ClaudeMemoryTool("test-api-key")
})

View file

@ -1,5 +1,5 @@
import Supermemory from "supermemory"
import { getContainerTags } from "./tools-shared"
import { deleteDocumentById, getContainerTags } from "./tools-shared"
import type { SupermemoryToolsConfig } from "./types"
// Claude Memory Tool Types
@ -37,6 +37,14 @@ export interface MemoryToolResult {
is_error: boolean
}
type ClaudeFileMetadata = Record<string, string | number | boolean | string[]>
interface ClaudeFileDocument {
documentId: string
content: string
metadata: ClaudeFileMetadata
}
/**
* Claude Memory Tool - Client-side implementation
* Maps Claude's memory tool commands to supermemory document operations
@ -44,6 +52,7 @@ export interface MemoryToolResult {
export class ClaudeMemoryTool {
private client: Supermemory
private containerTags: string[]
private scopeContainerTags: [string, ...string[]]
private memoryContainerPrefix: string
/**
@ -68,6 +77,7 @@ export class ClaudeMemoryTool {
// Get base container tags and add memory-specific tag
const baseContainerTags = getContainerTags(config)
this.scopeContainerTags = baseContainerTags
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]
}
@ -193,44 +203,89 @@ export class ClaudeMemoryTool {
*/
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
try {
// Search for all memory files
const response = await this.client.search({
q: "*", // Search for all
...(this.containerTags[0]
? { containerTag: this.containerTags[0] }
: {}),
limit: 100, // Get many files (max allowed)
searchMode: "hybrid",
})
// Document search returns ranked chunks, not a complete inventory. Walk
// every page of the document-list endpoint so files cannot disappear
// from a directory merely because they did not rank in a search page.
const documents: Supermemory.DocumentListResponse.Memory[] = []
let page = 1
if (!response.results) {
return {
success: true,
content: `Directory: ${dirPath}\n(empty)`,
}
while (true) {
const response = await this.client.documents.list({
containerTags: this.scopeContainerTags,
filters: {
AND: [
{ key: "claude_memory_type", value: "file" },
{
key: "file_path",
value: dirPath,
filterType: "string_contains",
},
],
},
includeContent: false,
limit: 100,
page,
})
documents.push(...response.memories)
if (page >= response.pagination.totalPages) break
page += 1
}
// Filter files that match the directory path and extract relative paths
const files: string[] = []
const dirs = new Set<string>()
const candidates: Array<{
document: Supermemory.DocumentListResponse.Memory
filePath: string
}> = []
for (const result of response.results) {
// Get the file path from metadata (since customId is normalized)
const filePath = result.metadata?.file_path as string
if (!filePath || !filePath.startsWith(dirPath)) continue
for (const document of documents) {
if (!this.isDocumentInConfiguredScope(document)) continue
// Get relative path from directory
const relativePath = filePath.substring(dirPath.length)
if (!relativePath) continue
const filePath = this.getDocumentFilePath(document)
if (!filePath || !filePath.startsWith(dirPath)) {
continue
}
candidates.push({ document, filePath })
}
// If path contains /, it's in a subdirectory
const slashIndex = relativePath.indexOf("/")
if (slashIndex > 0) {
// It's a subdirectory
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
} else if (relativePath !== "") {
// It's a file in this directory
files.push(relativePath)
// Full GETs are required to verify hidden project tags. Keep them bounded
// so large directories do not become a long serial chain or a burst of
// unbounded requests.
const verificationBatchSize = 8
for (
let index = 0;
index < candidates.length;
index += verificationBatchSize
) {
const batch = candidates.slice(index, index + verificationBatchSize)
const verified = await Promise.all(
batch.map(async (candidate) =>
(await this.isDirectoryDocumentInExactScope(candidate.document))
? candidate
: undefined,
),
)
for (const candidate of verified) {
if (!candidate) continue
const { filePath } = candidate
// Get relative path from directory
const relativePath = filePath.substring(dirPath.length)
if (!relativePath) continue
// If path contains /, it's in a subdirectory
const slashIndex = relativePath.indexOf("/")
if (slashIndex > 0) {
// It's a subdirectory
dirs.add(`${relativePath.substring(0, slashIndex)}/`)
} else if (relativePath !== "") {
// It's a file in this directory
files.push(relativePath)
}
}
}
@ -264,10 +319,8 @@ export class ClaudeMemoryTool {
viewRange?: [number, number],
): Promise<MemoryResponse> {
try {
// Same lookup as every mutating command: limit 5 so the exact
// customId match is findable among semantic near-neighbours.
// With the old limit of 1, a similarly-named file ranking first
// made this return the wrong file's contents as a success.
// Resolve the exact document inside the configured scope so reads and
// mutations use the complete stored file, not one ranked search chunk.
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
@ -278,7 +331,7 @@ export class ClaudeMemoryTool {
const document = readResult.document
let content: string = document.raw || document.content || ""
let content = document.content
// Apply line range if specified
if (viewRange) {
@ -376,8 +429,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
// Check if old_str exists in the content
if (!originalContent.includes(oldStr)) {
@ -435,8 +487,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
const lines = originalContent.split("\n")
// Validate line number
@ -490,9 +541,7 @@ export class ClaudeMemoryTool {
}
}
const documentId =
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
await this.client.documents.delete(documentId)
await deleteDocumentById(this.client, readResult.document.documentId)
return {
success: true,
@ -531,8 +580,7 @@ export class ClaudeMemoryTool {
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const originalContent = readResult.document.content
const newNormalizedId = this.normalizePathToCustomId(newPath)
// Create new document with new path
@ -552,8 +600,7 @@ export class ClaudeMemoryTool {
// customId — the add above already replaced the content.
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
if (oldNormalizedId !== newNormalizedId) {
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
await this.client.documents.delete(oldDocumentId)
await deleteDocumentById(this.client, readResult.document.documentId)
}
return {
@ -573,48 +620,124 @@ export class ClaudeMemoryTool {
*/
private async getFileDocument(filePath: string): Promise<{
success: boolean
document?: any
document?: ClaudeFileDocument
error?: string
}> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
let page = 1
const candidates = new Map<
string,
Supermemory.DocumentListResponse.Memory
>()
const response = await this.client.search({
q: normalizedId,
...(this.containerTags[0]
? { containerTag: this.containerTags[0] }
: {}),
limit: 5,
searchMode: "hybrid",
})
// customId values are only unique within an exact container-tag set in
// Mono. Resolve the matching document inside this tool's configured
// scope before fetching by internal ID; a direct get(customId) can pick
// another project/user's same-named file.
while (true) {
const response = await this.client.documents.list({
containerTags: this.scopeContainerTags,
filters: {
AND: [
{ key: "claude_memory_type", value: "file" },
{ key: "file_path", value: filePath },
],
},
includeContent: false,
limit: 100,
page,
})
// Only accept the exact customId match. Falling back to the top
// semantic hit would let callers read — and worse, modify or
// delete — a different file than the one they asked for.
const match = response.results?.find(
(r) =>
r.id === normalizedId ||
r.documents?.some((d) => d.id === normalizedId),
)
for (const document of response.memories) {
if (
document.customId === normalizedId &&
this.getDocumentFilePath(document) === filePath &&
this.isDocumentInConfiguredScope(document)
) {
candidates.set(document.id, document)
}
}
if (!match) {
if (page >= response.pagination.totalPages) break
page += 1
}
const exactMatches: Array<{
candidate: Supermemory.DocumentListResponse.Memory
document: Supermemory.DocumentGetResponse
}> = []
let hasUnverifiedCandidate = false
for (const candidate of candidates.values()) {
let document: Supermemory.DocumentGetResponse
try {
document = await this.client.documents.get(candidate.id)
} catch (error) {
if (error instanceof Supermemory.NotFoundError) continue
throw error
}
if (document.id !== candidate.id) {
hasUnverifiedCandidate = true
continue
}
if (
document.customId !== normalizedId ||
this.getDocumentFilePath(document) !== filePath ||
!this.hasExactContainerTags(document.containerTags)
) {
continue
}
exactMatches.push({ candidate, document })
}
if (exactMatches.length === 0) {
return {
success: false,
error: `File not found: ${filePath}`,
}
}
if (exactMatches.length > 1) {
return {
success: false,
error: `File path is ambiguous in the configured container scope: ${filePath}`,
}
}
if (hasUnverifiedCandidate) {
return {
success: false,
error: `File path could not be resolved unambiguously in the configured container scope: ${filePath}`,
}
}
const content = match.chunk || match.memory || ""
const documentId = match.documents?.[0]?.id ?? match.id
const match = exactMatches[0]
if (!match) {
return { success: false, error: `File not found: ${filePath}` }
}
const { candidate, document } = match
const content =
typeof document.content === "string"
? document.content
: typeof document.raw === "string"
? document.raw
: undefined
if (content === undefined) {
return {
success: false,
error: `File content unavailable: ${filePath}`,
}
}
const metadata =
document.metadata &&
typeof document.metadata === "object" &&
!Array.isArray(document.metadata)
? (document.metadata as ClaudeFileMetadata)
: {}
return {
success: true,
document: {
documentId,
content,
raw: content,
metadata: match.metadata,
},
document: { documentId: candidate.id, content, metadata },
}
} catch (error) {
return {
@ -624,6 +747,60 @@ export class ClaudeMemoryTool {
}
}
private getDocumentFilePath(document: {
metadata: unknown
}): string | undefined {
const metadata = document.metadata
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
return undefined
}
const metadataRecord = metadata as Record<string, unknown>
return typeof metadataRecord.file_path === "string"
? metadataRecord.file_path
: undefined
}
private isDocumentInConfiguredScope(
document: Supermemory.DocumentListResponse.Memory,
): boolean {
const documentTags = document.containerTags ?? []
const expectedTags = this.containerTags.filter(
(tag) => !tag.startsWith("sm_project_"),
)
return (
documentTags.length === expectedTags.length &&
documentTags.every((tag, index) => tag === expectedTags[index])
)
}
private async isDirectoryDocumentInExactScope(
document: Supermemory.DocumentListResponse.Memory,
): Promise<boolean> {
try {
// Mono strips internal project tags from every list response, so only a
// full get can prove that no hidden tags change this document's scope.
const fullDocument = await this.client.documents.get(document.id)
return (
fullDocument.id === document.id &&
this.hasExactContainerTags(fullDocument.containerTags)
)
} catch (error) {
if (!(error instanceof Supermemory.NotFoundError)) throw error
// A document can disappear between list and get. Skip stale entries
// instead of failing the entire directory view.
return false
}
}
private hasExactContainerTags(containerTags?: string[]): boolean {
return (
containerTags?.length === this.containerTags.length &&
containerTags.every((tag, index) => tag === this.containerTags[index])
)
}
/**
* Validate that path starts with /memories for security
*/

View file

@ -4,6 +4,7 @@ import {
DEFAULT_VALUES,
PARAMETER_DESCRIPTIONS,
TOOL_DESCRIPTIONS,
deleteDocumentByIdentifier,
getContainerTags,
} from "../tools-shared"
import { forgetMemoryRequest } from "../shared/forget-memory"
@ -14,7 +15,9 @@ import type { SupermemoryToolsConfig } from "../types"
*/
export interface MemorySearchResult {
success: boolean
results?: Awaited<ReturnType<Supermemory["search"]>>["results"]
results?: Awaited<
ReturnType<Supermemory["search"]["documents"]>
>["results"]
count?: number
error?: string
}
@ -31,7 +34,7 @@ export interface ProfileResult {
static: string[]
dynamic: string[]
}
searchResults?: Awaited<ReturnType<Supermemory["search"]>>
searchResults?: Awaited<ReturnType<Supermemory["profile"]>>["searchResults"]
error?: string
}
@ -159,6 +162,10 @@ export const memoryToolSchemas = {
type: "string",
description: PARAMETER_DESCRIPTIONS.documentId,
},
containerTag: {
type: "string",
description: PARAMETER_DESCRIPTIONS.documentContainerTag,
},
},
required: ["documentId"],
},
@ -248,12 +255,12 @@ export function createSearchMemoriesFunction(
limit?: number
}): Promise<MemorySearchResult> {
try {
const response = await client.search({
const response = await client.search.documents({
q: informationToGet,
...(containerTags[0] ? { containerTag: containerTags[0] } : {}),
containerTags,
limit,
threshold: DEFAULT_VALUES.chunkThreshold,
searchMode: "hybrid",
chunkThreshold: DEFAULT_VALUES.chunkThreshold,
includeFullDocs,
})
return {
@ -363,10 +370,12 @@ export function createDocumentListFunction(
page?: number
}): Promise<DocumentListResult> {
try {
const tag = containerTag || containerTags[0]
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
const response = await client.documents.list({
containerTags: [tag],
containerTags: scopeTags,
limit: limit || DEFAULT_VALUES.limit,
...(page !== undefined && { page }),
})
@ -392,15 +401,20 @@ export function createDocumentDeleteFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client } = createClient(apiKey, config)
const { client, containerTags } = createClient(apiKey, config)
return async function documentDelete({
documentId,
containerTag,
}: {
documentId: string
containerTag?: string
}): Promise<DocumentDeleteResult> {
try {
await client.documents.delete(documentId)
const scopeTags: [string, ...string[]] = containerTag
? [containerTag]
: containerTags
await deleteDocumentByIdentifier(client, documentId, scopeTags)
return {
success: true,

View file

@ -2,18 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool
// executions can be verified deterministically without network access.
const documentsDelete = vi.fn()
const documentsDeleteBulk = vi.fn()
const documentsGet = vi.fn()
const documentsList = vi.fn()
const searchMock = vi.fn()
const clientAdd = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = searchMock
add = clientAdd
documents = {
delete: documentsDelete,
deleteBulk: documentsDeleteBulk,
get: documentsGet,
list: documentsList,
add: vi.fn(),
}
@ -35,12 +35,20 @@ function executeTool(tool: unknown, args: Record<string, unknown>) {
}
beforeEach(() => {
documentsDelete.mockReset().mockResolvedValue(undefined)
documentsDeleteBulk.mockReset().mockResolvedValue({
success: true,
deletedCount: 1,
errors: [],
})
documentsGet.mockReset().mockResolvedValue({
id: "doc_123",
customId: "doc_123",
containerTags: ["sm_project_default"],
})
documentsList.mockReset().mockResolvedValue({
memories: [{ id: "doc_1", title: "Doc one" }],
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
})
searchMock.mockReset()
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
vi.unstubAllGlobals()
})
@ -53,7 +61,8 @@ describe("documentDelete", () => {
}
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith("doc_123")
expect(documentsGet).toHaveBeenCalledWith("doc_123")
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: ["doc_123"] })
})
})
@ -177,16 +186,30 @@ describe("memoryForget", () => {
describe("ClaudeMemoryTool", () => {
const FILE_PATH = "/memories/prefs.txt"
const CUSTOM_ID = "memories_prefs_txt"
const DOCUMENT_ID = "doc_file_1"
function mockFileDocument(content: string) {
searchMock.mockResolvedValue({
results: [
const metadata = {
claude_memory_type: "file",
file_path: FILE_PATH,
}
documentsList.mockResolvedValue({
memories: [
{
id: CUSTOM_ID,
chunk: content,
metadata: { file_path: FILE_PATH },
id: DOCUMENT_ID,
customId: CUSTOM_ID,
containerTags: ["claude_memory"],
metadata,
},
],
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
})
documentsGet.mockResolvedValue({
id: DOCUMENT_ID,
customId: CUSTOM_ID,
containerTags: ["sm_project_default", "claude_memory"],
content,
metadata,
})
}
@ -247,7 +270,7 @@ describe("ClaudeMemoryTool", () => {
})
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
})
it("rename removes the old document after creating the new one", async () => {
@ -264,6 +287,6 @@ describe("ClaudeMemoryTool", () => {
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ customId: "memories_renamed_txt" }),
)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
expect(documentsDeleteBulk).toHaveBeenCalledWith({ ids: [DOCUMENT_ID] })
})
})

View file

@ -2,48 +2,51 @@
* Shared constants and descriptions for Supermemory tools
*/
import type Supermemory from "supermemory"
import type { MemoryMode } from "./shared/types"
// Tool descriptions
export const TOOL_DESCRIPTIONS = {
searchMemories:
"Search (recall) stored memories for facts, preferences, history, and context about the user or any topic. Use proactively before answering whenever memory could help — do not wait for the user to explicitly ask you to search or recall. Search when the question touches personal context, past conversations, preferences, projects, people, plans, or anything you may have learned before. Results include memory/chunk IDs — use those IDs with memoryForget to remove a specific learned fact.",
"Search stored source documents for relevant facts, preferences, history, and other context. Use when explicitly asked to search or recall, or when past context could materially improve the response; do not invoke reflexively on every turn. Results contain document IDs and matching text chunks, not profile-memory IDs for memoryForget.",
addMemory:
"Add (remember) memories/details/information about the user or other facts or entities. Run when explicitly asked or when the user mentions any information generalizable beyond the context of the current conversation.",
getProfile:
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query. Profile and search result entries may include memory IDs useful for memoryForget.",
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Profile entries are text without IDs. Provide a query to include searchResults, whose memory entries may include IDs usable with memoryForget.",
documentList:
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Returns document IDs for documentDelete — not memory IDs for memoryForget. Use to browse raw stored content before permanently removing a source.",
"List stored source documents (conversations, URLs, files, pasted text) with pagination. Configured container tags are treated as the default union; an optional containerTag replaces that union with one tag for this operation. Returns document metadata and IDs for documentDelete, not raw document content or memory IDs for memoryForget.",
documentDelete:
"Permanently delete a stored document and ALL memories extracted from it (hard delete). Use document IDs from documentList. Use when the user wants to remove an entire conversation, file, URL, or other source — not when correcting a single learned fact (use memoryForget for that).",
"Permanently delete a stored source document. Memories extracted from that source are soft-forgotten so they no longer appear in profile or search; they are not hard-deleted. Use a document ID or customId when removing an entire conversation, file, URL, or other source. The effective scope is the configured container-tag union, or the explicit one-tag override; if documentList used an override, pass the same value here. To forget one learned fact, use memoryForget instead.",
documentAdd:
"Store a source document for asynchronous processing and automatic memory extraction. Use when the user gives you raw content to ingest — a pasted text blob, conversation transcript, chat history, notes, URL, article link, or other substantial text — rather than a single atomic fact (use addMemory for one short generalizable sentence). The document is queued immediately; Supermemory post-processes it in the background (chunking, embedding, indexing) and extracts profile memories automatically — you do not need to call addMemory for facts buried inside the document. Good for saving full conversations, long-form notes, knowledge-base articles, meeting transcripts, or any large body of text the user wants remembered beyond this chat turn. Processing may take a moment; extracted memories appear in profile/search after indexing completes.",
memoryForget:
"Soft-delete a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId (preferred — from searchMemories or getProfile) OR memoryContent for an exact text match. Use when the user retracts or corrects a specific fact (e.g. 'forget I like tea', 'that's wrong'). To remove an entire conversation or file, use documentDelete instead.",
"Soft-forget a single extracted profile memory (a learned fact) so it no longer appears in profile or search. Does NOT delete source documents. Provide memoryId from query-backed getProfile searchResults, or memoryContent for an exact text match; document and chunk IDs from searchMemories are not valid. Use when the user retracts or corrects a specific fact. To remove an entire source, use documentDelete instead.",
} as const
// Parameter descriptions
export const PARAMETER_DESCRIPTIONS = {
informationToGet:
"What to look up in memory — keywords from the user's message, topic, entity names, or question phrasing. Search even when the user did not explicitly ask you to recall.",
"What to look up in stored context — keywords from the user's message, topic, entity names, or question phrasing.",
includeFullDocs:
"Whether to include the full document content in the response. Defaults to true for better AI context.",
limit: "Maximum number of results to return",
memory:
"The text content of the memory to add. This should be a single sentence or a short paragraph.",
containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)",
documentContainerTag:
"Optional one-tag scope override. When deleting a document returned by documentList with a containerTag override, pass the same value here. In strict mode, pass null to use the configured union.",
query: "Optional search query to include relevant search results",
page: "Page number to fetch, 1-based (default: 1)",
documentId:
"Document ID from documentList — permanently deletes the source document and all extracted memories. Not a profile memory ID.",
"Document ID from documentList, or the document customId. Permanently deletes the source document and soft-forgets its extracted memories. If documentList used a containerTag override, pass it again. Not a profile-memory ID.",
content:
"Document body to store — plain text, a conversation transcript, a long pasted blob, or a URL to a webpage/PDF/image/video. Content is queued and memories are extracted automatically after background processing; do not split into addMemory calls.",
title: "Optional title for the document",
description: "Optional description for the document",
memoryId:
"Profile memory ID from searchMemories or getProfile — soft-deletes one learned fact via memoryForget. Not a document ID.",
"Profile-memory ID from query-backed getProfile searchResults. Soft-forgets one learned fact; document and chunk IDs from searchMemories are not valid.",
memoryContent:
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, search first and use memoryId.",
"Exact text of the profile memory to forget (alternative to memoryId). Must match precisely; if unsure, query getProfile and use a search-result memory ID.",
reason: "Optional reason recorded when forgetting (e.g. outdated, user correction)",
} as const
@ -57,7 +60,7 @@ export const DEFAULT_VALUES = {
// Container tag constants
export const CONTAINER_TAG_CONSTANTS = {
projectPrefix: "sm_project_",
defaultTags: ["sm_project_default"] as string[],
defaultTags: ["sm_project_default"] as const,
} as const
/**
@ -66,16 +69,167 @@ export const CONTAINER_TAG_CONSTANTS = {
export function getContainerTags(config?: {
projectId?: string
containerTags?: string[]
}): string[] {
}): [string, ...string[]] {
if (config?.projectId !== undefined && config.containerTags !== undefined) {
throw new Error(
"Supermemory tools config accepts either projectId or containerTags, not both.",
)
}
if (config?.projectId) {
if (config?.projectId !== undefined) {
if (config.projectId.trim() === "") {
throw new Error("Supermemory tools config requires a non-empty projectId.")
}
return [`${CONTAINER_TAG_CONSTANTS.projectPrefix}${config.projectId}`]
}
return config?.containerTags ?? CONTAINER_TAG_CONSTANTS.defaultTags
if (config?.containerTags !== undefined) {
const [firstTag, ...remainingTags] = config.containerTags
if (
firstTag === undefined ||
config.containerTags.some((tag) => tag.trim() === "")
) {
throw new Error(
"Supermemory tools config requires at least one non-empty containerTag.",
)
}
return [firstTag, ...remainingTags]
}
return [...CONTAINER_TAG_CONSTANTS.defaultTags]
}
/** Delete exactly one document by its internal ID. */
export async function deleteDocumentById(
client: Supermemory,
documentId: string,
): Promise<void> {
const response = await client.documents.deleteBulk({ ids: [documentId] })
if (response.success && response.deletedCount === 1) return
const detail = response.errors?.find((error) => error.id === documentId)?.error
throw new Error(
detail
? `Failed to delete document ${documentId}: ${detail}`
: `Failed to delete document ${documentId}: expected one deletion, received ${response.deletedCount}`,
)
}
/**
* Resolve an internal ID or customId inside the effective container-tag union,
* then delete the exact internal document ID. Internal IDs take precedence over
* customId matches.
*/
export async function deleteDocumentByIdentifier(
client: Supermemory,
documentIdentifier: string,
containerTags: readonly [string, ...string[]],
): Promise<void> {
const directMatch = await getDocumentIfFound(client, documentIdentifier)
if (
directMatch?.id === documentIdentifier &&
hasContainerTagOverlap(directMatch.containerTags, containerTags)
) {
await deleteDocumentById(client, directMatch.id)
return
}
const candidateIds = new Set<string>()
let hasInternalIdCandidate = false
let page = 1
while (true) {
const response = await client.documents.list({
containerTags: [...containerTags],
includeContent: false,
limit: 100,
page,
})
for (const document of response.memories) {
if (document.id === documentIdentifier) {
hasInternalIdCandidate = true
}
if (
document.id === documentIdentifier ||
document.customId === documentIdentifier
) {
candidateIds.add(document.id)
}
}
if (page >= response.pagination.totalPages) break
page += 1
}
let exactIdMatch: string | undefined
let hasUnverifiedCandidate = false
const customIdMatches: string[] = []
for (const candidateId of candidateIds) {
const document = await getDocumentIfFound(client, candidateId)
if (document?.id !== candidateId) {
hasUnverifiedCandidate = true
continue
}
if (!hasContainerTagOverlap(document.containerTags, containerTags)) {
continue
}
if (document.id === documentIdentifier) {
exactIdMatch = document.id
break
}
if (document.customId === documentIdentifier) {
customIdMatches.push(document.id)
} else {
hasUnverifiedCandidate = true
}
}
if (exactIdMatch) {
await deleteDocumentById(client, exactIdMatch)
return
}
if (hasInternalIdCandidate) {
throw new Error(
`Document ID ${documentIdentifier} could not be verified safely in the configured container scope.`,
)
}
if (hasUnverifiedCandidate) {
throw new Error(
`Document identifier ${documentIdentifier} could not be resolved unambiguously in the configured container scope.`,
)
}
if (customIdMatches.length === 1) {
await deleteDocumentById(client, customIdMatches[0] as string)
return
}
if (customIdMatches.length > 1) {
throw new Error(
`Document customId ${documentIdentifier} is ambiguous in the configured container scope.`,
)
}
throw new Error(
`Document ${documentIdentifier} was not found in the configured container scope.`,
)
}
async function getDocumentIfFound(client: Supermemory, documentId: string) {
try {
return await client.documents.get(documentId)
} catch (error) {
if (isNotFoundError(error)) return undefined
throw error
}
}
function isNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"status" in error &&
error.status === 404
)
}
function hasContainerTagOverlap(
actual: string[] | undefined,
expected: readonly string[],
): boolean {
return actual?.some((tag) => expected.includes(tag)) ?? false
}
/**

View file

@ -18,7 +18,11 @@ import {
type Logger,
type MemoryMode,
} from "../shared"
import type { SupermemoryVoltAgent, VoltAgentMessage } from "./types"
import type {
SearchFilters,
SupermemoryVoltAgent,
VoltAgentMessage,
} from "./types"
/**
* Context for Supermemory middleware operations.
@ -47,7 +51,7 @@ export interface SupermemoryMiddlewareContext {
limit?: number
rerank?: boolean
rewriteQuery?: boolean
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
filters?: SearchFilters
include?: {
chunks?: boolean
documents?: boolean
@ -258,23 +262,7 @@ export const enhanceMessagesWithMemories = async (
if (useAdvancedSearch && ctx.mode !== "profile") {
ctx.logger.info("Using advanced search with custom parameters")
const searchParams: {
q: string
containerTag: string
threshold?: number
limit?: number
rerank?: boolean
rewriteQuery?: boolean
filters?: { OR: Array<unknown> } | { AND: Array<unknown> }
include?: {
chunks?: boolean
documents?: boolean
forgottenMemories?: boolean
relatedMemories?: boolean
summaries?: boolean
}
searchMode?: "memories" | "documents" | "hybrid"
} = {
const searchParams: Supermemory.SearchParams = {
q: queryText,
containerTag: ctx.containerTag,
}
@ -288,31 +276,32 @@ export const enhanceMessagesWithMemories = async (
if (ctx.include !== undefined) searchParams.include = ctx.include
if (ctx.searchMode !== undefined) searchParams.searchMode = ctx.searchMode
const response = await ctx.client.search.memories(searchParams)
const response = await ctx.client.search(searchParams)
// Hybrid search returns both memory entries (`memory` field) and
// document chunks (`chunk` field). Handle both.
type SearchResult = {
memory?: string
chunk?: string
metadata?: Record<string, unknown>
}
const formattedMemories = response.results
.map((result: SearchResult) => {
const text = result.memory || result.chunk
return text ? `- ${text}` : null
})
.filter(Boolean)
// document chunks (`chunk` field). Normalize both for prompt templates.
const searchResults = response.results.flatMap((result) => {
const memory = result.memory ?? result.chunk
if (!memory) {
return []
}
return [
{
memory,
...(result.metadata ? { metadata: result.metadata } : {}),
},
]
})
const formattedMemories = searchResults
.map((result) => `- ${result.memory}`)
.join("\n")
memories = ctx.promptTemplate
? ctx.promptTemplate({
userMemories: "",
generalSearchMemories: formattedMemories,
searchResults: response.results as Array<{
memory: string
metadata?: Record<string, unknown>
}>,
searchResults,
})
: `The following are relevant memories and context about this user retrieved from previous interactions. Use these to personalize your response:\n\n${formattedMemories}`
} else {

View file

@ -5,6 +5,7 @@
* Supermemory by providing hooks that inject memories before LLM calls.
*/
import type Supermemory from "supermemory"
import type {
PromptTemplate,
MemoryMode,
@ -58,7 +59,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
/**
* Advanced filters to apply to the search using AND/OR logic.
* Example: { OR: [{ metadata: { type: "note" } }, { metadata: { type: "conversation" } }] }
* Example: { OR: [{ key: "type", value: "note" }, { key: "type", value: "conversation" }] }
*
* Note: Only effective when mode is "query" or "full". Ignored in "profile" mode.
*/
@ -99,7 +100,7 @@ export interface SupermemoryVoltAgent extends SupermemoryBaseOptions {
/**
* Advanced search filters using AND/OR logic
*/
export type SearchFilters = { OR: Array<unknown> } | { AND: Array<unknown> }
export type SearchFilters = NonNullable<Supermemory.SearchParams["filters"]>
/**
* Options for including additional data in search results