diff options
| author | Dhravya Shah <[email protected]> | 2025-09-29 13:40:56 -0700 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2025-09-29 13:40:56 -0700 |
| commit | 53bc296155b4f22f392dbef067c818eb939f3b91 (patch) | |
| tree | 9a4468118e86abb7ee4dedfc13bbbd4287970c3d /packages/tools/src | |
| parent | fix: build (diff) | |
| download | supermemory-53bc296155b4f22f392dbef067c818eb939f3b91.tar.xz supermemory-53bc296155b4f22f392dbef067c818eb939f3b91.zip | |
feat: Claude memory integration
Diffstat (limited to 'packages/tools/src')
| -rw-r--r-- | packages/tools/src/claude-memory-simple-example.ts | 71 | ||||
| -rw-r--r-- | packages/tools/src/claude-memory.ts | 564 |
2 files changed, 635 insertions, 0 deletions
diff --git a/packages/tools/src/claude-memory-simple-example.ts b/packages/tools/src/claude-memory-simple-example.ts new file mode 100644 index 00000000..8ce21f83 --- /dev/null +++ b/packages/tools/src/claude-memory-simple-example.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +/** + * Simple Claude Memory Tool Example + * Shows the cleanest way to integrate Claude's memory tool with supermemory + */ + +import Anthropic from '@anthropic-ai/sdk' +import { createClaudeMemoryTool } from './claude-memory' + +const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY!, +}) + +const memoryTool = createClaudeMemoryTool(process.env.SUPERMEMORY_API_KEY!, { + projectId: 'my-app', +}) + +async function chatWithMemory(userMessage: string) { + // Send message to Claude with memory tool + const response = await anthropic.beta.messages.create({ + model: 'claude-sonnet-4-5', + max_tokens: 2048, + messages: [{ role: 'user', content: userMessage }], + tools: [{ type: 'memory_20250818', name: 'memory' }], + betas: ['context-management-2025-06-27'], + }) + + // Handle any memory tool calls + const toolResults = [] + for (const block of response.content) { + if (block.type === 'tool_use' && block.name === 'memory') { + const toolResult = await memoryTool.handleCommandForToolResult( + block.input as any, + block.id + ) + toolResults.push(toolResult) + } + } + + // Send tool results back to Claude if needed + if (toolResults.length > 0) { + const finalResponse = await anthropic.beta.messages.create({ + model: 'claude-sonnet-4-5', + max_tokens: 2048, + messages: [ + { role: 'user', content: userMessage }, + { role: 'assistant', content: response.content }, + { role: 'user', content: toolResults }, + ], + tools: [{ type: 'memory_20250818', name: 'memory' }], + betas: ['context-management-2025-06-27'], + }) + + return finalResponse + } + + return response +} + +// Example usage +async function example() { + const response = await chatWithMemory( + "Remember that I prefer React with TypeScript for my projects" + ) + + console.log(response.content[0]) +} + +if (import.meta.main) { + example() +}
\ No newline at end of file diff --git a/packages/tools/src/claude-memory.ts b/packages/tools/src/claude-memory.ts new file mode 100644 index 00000000..49ff39a7 --- /dev/null +++ b/packages/tools/src/claude-memory.ts @@ -0,0 +1,564 @@ +import Supermemory from "supermemory" +import type { SupermemoryToolsConfig } from "./types" +import { getContainerTags } from "./shared" + +// 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 (replace / with --) + */ + private normalizePathToCustomId(path: string): string { + return path.replace(/\//g, "--") + } + + /** + * Convert customId back to file path (replace -- with /) + */ + private customIdToPath(customId: string): string { + return customId.replace(/--/g, "/") + } + + 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.customId === normalizedId) + const document = exactMatch || response.results?.[0] + + if (!document) { + return { + success: false, + error: `File not found: ${filePath}`, + } + } + + let content = document.raw || 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, index) => { + 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.memories.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.memories.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.memories.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.memories.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.customId === 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) +}
\ No newline at end of file |