supermemory/packages/tools/src/claude-memory.ts
nexxeln 9553434c9a mastra integration (#717)
adds withSupermemory wrapper and input/output processors for
mastra agents:

- input processor fetches and injects memories into system prompt
before llm calls
- output processor saves conversations to supermemory after
responses
- supports profile, query, and full memory search modes
- includes custom prompt templates and requestcontext support

const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions:
"..." },
"user-123",
{ mode: "full", addMemory: "always", threadId: "conv-456" }
))

includes docs as well

this pr also reworks how the tools package works into shared modules
2026-02-03 00:43:08 +00:00

618 lines
15 KiB
TypeScript

import Supermemory from "supermemory"
import { getContainerTags } from "./tools-shared"
import type { SupermemoryToolsConfig } from "./types"
// Claude Memory Tool Types
export interface ClaudeMemoryConfig extends SupermemoryToolsConfig {
memoryContainerTag?: string
}
export interface MemoryCommand {
command: "view" | "create" | "str_replace" | "insert" | "delete" | "rename"
path: string
// view specific
view_range?: [number, number]
// create specific
file_text?: string
// str_replace specific
old_str?: string
new_str?: string
// insert specific
insert_line?: number
insert_text?: string
// rename specific
new_path?: string
}
export interface MemoryResponse {
success: boolean
content?: string
error?: string
}
export interface MemoryToolResult {
type: "tool_result"
tool_use_id: string
content: string
is_error: boolean
}
/**
* Claude Memory Tool - Client-side implementation
* Maps Claude's memory tool commands to supermemory document operations
*/
export class ClaudeMemoryTool {
private client: Supermemory
private containerTags: string[]
private memoryContainerPrefix: string
/**
* Normalize file path to be used as customId
* Converts /memories/file.txt -> memories_file_txt
*/
private normalizePathToCustomId(path: string): string {
return path
.replace(/^\//, "") // Remove leading slash
.replace(/\//g, "_") // Replace / with _
.replace(/\./g, "_") // Replace . with _
}
constructor(apiKey: string, config?: ClaudeMemoryConfig) {
this.client = new Supermemory({
apiKey,
...(config?.baseUrl && { baseURL: config.baseUrl }),
})
// Use custom memory container tag or default
this.memoryContainerPrefix = config?.memoryContainerTag || "claude_memory"
// Get base container tags and add memory-specific tag
const baseContainerTags = getContainerTags(config)
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]
}
/**
* Main method to handle all Claude memory tool commands
*/
async handleCommand(command: MemoryCommand): Promise<MemoryResponse> {
try {
// Validate path security
if (!this.isValidPath(command.path)) {
return {
success: false,
error: `Invalid path: ${command.path}. All paths must start with /memories/`,
}
}
switch (command.command) {
case "view":
return await this.view(command.path, command.view_range)
case "create":
if (!command.file_text) {
return {
success: false,
error: "file_text is required for create command",
}
}
return await this.create(command.path, command.file_text)
case "str_replace":
if (!command.old_str || !command.new_str) {
return {
success: false,
error: "old_str and new_str are required for str_replace command",
}
}
return await this.strReplace(
command.path,
command.old_str,
command.new_str,
)
case "insert":
if (command.insert_line === undefined || !command.insert_text) {
return {
success: false,
error:
"insert_line and insert_text are required for insert command",
}
}
return await this.insert(
command.path,
command.insert_line,
command.insert_text,
)
case "delete":
return await this.delete(command.path)
case "rename":
if (!command.new_path) {
return {
success: false,
error: "new_path is required for rename command",
}
}
return await this.rename(command.path, command.new_path)
default:
return {
success: false,
error: `Unknown command: ${(command as any).command}`,
}
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
/**
* Handle command and return properly formatted tool result
*/
async handleCommandForToolResult(
command: MemoryCommand,
toolUseId: string,
): Promise<MemoryToolResult> {
const response = await this.handleCommand(command)
return {
type: "tool_result",
tool_use_id: toolUseId,
content: response.success
? response.content || "Operation completed successfully"
: `Error: ${response.error}`,
is_error: !response.success,
}
}
/**
* View command: List directory contents or read file with optional line range
*/
private async view(
path: string,
viewRange?: [number, number],
): Promise<MemoryResponse> {
// If path ends with / or is exactly /memories, it's a directory listing request
if (path.endsWith("/") || path === "/memories") {
// Normalize path to end with /
const dirPath = path.endsWith("/") ? path : `${path}/`
return await this.listDirectory(dirPath)
}
// Otherwise, read the specific file
return await this.readFile(path, viewRange)
}
/**
* List directory contents
*/
private async listDirectory(dirPath: string): Promise<MemoryResponse> {
try {
// Search for all memory files
const response = await this.client.search.execute({
q: "*", // Search for all
containerTags: this.containerTags,
limit: 100, // Get many files (max allowed)
includeFullDocs: false,
})
if (!response.results) {
return {
success: true,
content: `Directory: ${dirPath}\n(empty)`,
}
}
// Filter files that match the directory path and extract relative paths
const files: string[] = []
const dirs = new Set<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
// 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)
}
}
// Format directory listing
const entries = [...Array.from(dirs).sort(), ...files.sort()]
if (entries.length === 0) {
return {
success: true,
content: `Directory: ${dirPath}\n(empty)`,
}
}
return {
success: true,
content: `Directory: ${dirPath}\n${entries.map((entry) => `- ${entry}`).join("\n")}`,
}
} catch (error) {
return {
success: false,
error: `Failed to list directory: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Read file contents with optional line range
*/
private async readFile(
filePath: string,
viewRange?: [number, number],
): Promise<MemoryResponse> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
const response = await this.client.search.execute({
q: normalizedId,
containerTags: this.containerTags,
limit: 1,
includeFullDocs: true,
})
// Try to find exact match by customId
const exactMatch = response.results?.find(
(r) => r.documentId === normalizedId,
)
const document = exactMatch || response.results?.[0]
if (!document) {
return {
success: false,
error: `File not found: ${filePath}`,
}
}
let content = document.content || ""
// Apply line range if specified
if (viewRange) {
const lines = content.split("\n")
const [startLine, endLine] = viewRange
const selectedLines = lines.slice(startLine - 1, endLine)
// Format with line numbers
const numberedLines = selectedLines.map(
(line: string, index: number) => {
const lineNum = startLine + index
return `${lineNum.toString().padStart(4)}\t${line}`
},
)
content = numberedLines.join("\n")
} else {
// Format all lines with line numbers
const lines = content.split("\n")
const numberedLines = lines.map((line, index) => {
const lineNum = index + 1
return `${lineNum.toString().padStart(4)}\t${line}`
})
content = numberedLines.join("\n")
}
return {
success: true,
content,
}
} catch (error) {
return {
success: false,
error: `Failed to read file: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Create command: Create or overwrite a memory file
*/
private async create(
filePath: string,
fileText: string,
): Promise<MemoryResponse> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
const _response = await this.client.add({
content: fileText,
customId: normalizedId,
containerTags: this.containerTags,
metadata: {
claude_memory_type: "file",
file_path: filePath,
line_count: fileText.split("\n").length,
created_by: "claude_memory_tool",
last_modified: new Date().toISOString(),
},
})
return {
success: true,
content: `File created: ${filePath}`,
}
} catch (error) {
return {
success: false,
error: `Failed to create file: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* String replace command: Replace text in existing file
*/
private async strReplace(
filePath: string,
oldStr: string,
newStr: string,
): Promise<MemoryResponse> {
try {
// First, find and read the existing file
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
success: false,
error: readResult.error || "File not found",
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
// Check if old_str exists in the content
if (!originalContent.includes(oldStr)) {
return {
success: false,
error: `String not found in file: "${oldStr}"`,
}
}
// Replace the string
const newContent = originalContent.replace(oldStr, newStr)
// Update the document
const normalizedId = this.normalizePathToCustomId(filePath)
const _updateResponse = await this.client.add({
content: newContent,
customId: normalizedId,
containerTags: this.containerTags,
metadata: {
...readResult.document.metadata,
line_count: newContent.split("\n").length,
last_modified: new Date().toISOString(),
},
})
return {
success: true,
content: `String replaced in file: ${filePath}`,
}
} catch (error) {
return {
success: false,
error: `Failed to replace string: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Insert command: Insert text at specific line
*/
private async insert(
filePath: string,
insertLine: number,
insertText: string,
): Promise<MemoryResponse> {
try {
// First, find and read the existing file
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
success: false,
error: readResult.error || "File not found",
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const lines = originalContent.split("\n")
// Validate line number
if (insertLine < 1 || insertLine > lines.length + 1) {
return {
success: false,
error: `Invalid line number: ${insertLine}. File has ${lines.length} lines.`,
}
}
// Insert the text (insertLine is 1-based)
lines.splice(insertLine - 1, 0, insertText)
const newContent = lines.join("\n")
// Update the document
const normalizedId = this.normalizePathToCustomId(filePath)
await this.client.add({
content: newContent,
customId: normalizedId,
containerTags: this.containerTags,
metadata: {
...readResult.document.metadata,
line_count: newContent.split("\n").length,
last_modified: new Date().toISOString(),
},
})
return {
success: true,
content: `Text inserted at line ${insertLine} in file: ${filePath}`,
}
} catch (error) {
return {
success: false,
error: `Failed to insert text: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Delete command: Delete memory file
*/
private async delete(filePath: string): Promise<MemoryResponse> {
try {
// Find the document first
const readResult = await this.getFileDocument(filePath)
if (!readResult.success || !readResult.document) {
return {
success: false,
error: readResult.error || "File not found",
}
}
// Delete using the document ID
// Note: We'll need to implement this based on supermemory's delete API
// For now, we'll return a success message
return {
success: true,
content: `File deleted: ${filePath}`,
}
} catch (error) {
return {
success: false,
error: `Failed to delete file: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Rename command: Move/rename memory file
*/
private async rename(
oldPath: string,
newPath: string,
): Promise<MemoryResponse> {
try {
// Validate new path
if (!this.isValidPath(newPath)) {
return {
success: false,
error: `Invalid new path: ${newPath}. All paths must start with /memories/`,
}
}
// Get the existing document
const readResult = await this.getFileDocument(oldPath)
if (!readResult.success || !readResult.document) {
return {
success: false,
error: readResult.error || "File not found",
}
}
const originalContent =
readResult.document.raw || readResult.document.content || ""
const newNormalizedId = this.normalizePathToCustomId(newPath)
// Create new document with new path
await this.client.add({
content: originalContent,
customId: newNormalizedId,
containerTags: this.containerTags,
metadata: {
...readResult.document.metadata,
file_path: newPath,
last_modified: new Date().toISOString(),
},
})
// Delete the old document (would need proper delete API)
return {
success: true,
content: `File renamed from ${oldPath} to ${newPath}`,
}
} catch (error) {
return {
success: false,
error: `Failed to rename file: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
/**
* Helper: Get document by file path
*/
private async getFileDocument(filePath: string): Promise<{
success: boolean
document?: any
error?: string
}> {
try {
const normalizedId = this.normalizePathToCustomId(filePath)
const response = await this.client.search.execute({
q: normalizedId,
containerTags: this.containerTags,
limit: 5,
includeFullDocs: true,
})
// Try to find exact match by customId first
const exactMatch = response.results?.find(
(r) => r.documentId === normalizedId,
)
const document = exactMatch || response.results?.[0]
if (!document) {
return {
success: false,
error: `File not found: ${filePath}`,
}
}
return {
success: true,
document,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
/**
* Validate that path starts with /memories for security
*/
private isValidPath(path: string): boolean {
return (
(path.startsWith("/memories/") || path === "/memories") &&
!path.includes("../") &&
!path.includes("..\\")
)
}
}
/**
* Create a Claude memory tool instance
*/
export function createClaudeMemoryTool(
apiKey: string,
config?: ClaudeMemoryConfig,
) {
return new ClaudeMemoryTool(apiKey, config)
}