aboutsummaryrefslogtreecommitdiff
path: root/packages/tools/src
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2025-09-29 13:40:56 -0700
committerDhravya Shah <[email protected]>2025-09-29 13:40:56 -0700
commit53bc296155b4f22f392dbef067c818eb939f3b91 (patch)
tree9a4468118e86abb7ee4dedfc13bbbd4287970c3d /packages/tools/src
parentfix: build (diff)
downloadsupermemory-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.ts71
-rw-r--r--packages/tools/src/claude-memory.ts564
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