mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
fix(tools): harden seven-tool parity
This commit is contained in:
parent
d04bb3b7b1
commit
d21d661380
11 changed files with 614 additions and 193 deletions
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue