diff --git a/package-lock.json b/package-lock.json index e452864..8dc0e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-memory-mcp", - "version": "1.0.0", + "version": "1.0.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-memory-mcp", - "version": "1.0.0", + "version": "1.0.31", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.3", diff --git a/package.json b/package.json index 6ceda9d..98df138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-memory-mcp", - "version": "1.0.10", + "version": "1.0.34", "description": "Simple memory MCP server for storing and retrieving memories with tags", "main": "dist/index.js", "type": "module", diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index 500c407..dac9036 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -485,6 +485,74 @@ export class MemoryService { return result.changes; } + /** + * Update a memory's content and/or tags by hash + */ + update(hash: string, content?: string, tags?: string[]): boolean { + if (!this.db) { + throw new Error('Database not initialized'); + } + + // Validate that at least one field is being updated + if (content === undefined && tags === undefined) { + throw new Error('Must provide either content or tags to update'); + } + + // Validate content size if provided + if (content !== undefined && content.length > this.maxContentSize) { + throw new Error(`Content exceeds maximum size of ${this.maxContentSize} characters`); + } + + // Check if memory exists + const existing = this.stmts.getMemoryByHash.get(hash) as any; + if (!existing) { + return false; + } + + try { + // Prepare statements outside transaction + const updateContentStmt = content !== undefined ? + this.db!.prepare(`UPDATE memories SET content = ? WHERE hash = ?`) : null; + + const deleteTagsStmt = tags !== undefined ? + this.db!.prepare(`DELETE FROM tags WHERE memory_id = ?`) : null; + + const updateMemory = this.db.transaction(() => { + // Update content if provided + if (content !== undefined && updateContentStmt) { + updateContentStmt.run(content, hash); + } + + // Update tags if provided + if (tags !== undefined && deleteTagsStmt) { + // Delete existing tags for this memory + deleteTagsStmt.run(existing.id); + + // Insert new tags + for (const tag of tags) { + const normalizedTag = tag.trim().toLowerCase(); + if (normalizedTag) { + this.stmts.insertTag.run(existing.id, normalizedTag); + } + } + } + + return true; + }); + + const updated = updateMemory(); + debugLogHash('MemoryService: Updated memory with hash:', hash); + + // Backup if needed (lazy, throttled) + this.backup?.backupIfNeeded(); + + return updated; + } catch (error: any) { + debugLog('MemoryService: Error updating memory:', error); + throw error; + } + } + /** * Bulk link memories in a single transaction for performance * Returns the number of relationships successfully created diff --git a/src/tests/memory-server-tests.ts b/src/tests/memory-server-tests.ts index 32c044d..f845dbb 100644 --- a/src/tests/memory-server-tests.ts +++ b/src/tests/memory-server-tests.ts @@ -375,6 +375,185 @@ async function testSearchWithRelationships(): Promise { } } +/** + * Test updating memory content + */ +async function testUpdateMemoryContent(): Promise { + // Store a memory first + const storeResult = await executeCommand(['store-memory', '--content', 'Original content', '--tags', 'update-test']); + const storeOutput = parseJsonOutput(storeResult.stdout); + + if (!storeOutput?.success || !storeOutput.hash) { + throw new Error('Failed to store memory for update test'); + } + + const hash = storeOutput.hash; + console.log(` Stored memory with hash: ${formatHash(hash)}`); + + // Update the content + const updateResult = await executeCommand(['update-memory', '--hash', hash, '--content', 'Updated content']); + + if (updateResult.exitCode !== 0) { + throw new Error(`Update memory failed: ${updateResult.stderr}`); + } + + const updateOutput = parseJsonOutput(updateResult.stdout); + if (!updateOutput?.success) { + throw new Error('Update memory did not return expected success response'); + } + + // Verify the update by searching + const searchResult = await executeCommand(['search-memory', '--query', 'Updated content']); + const searchOutput = parseJsonOutput(searchResult.stdout); + + if (!searchOutput?.memories || searchOutput.memories.length === 0) { + throw new Error('Updated memory not found in search'); + } + + const updatedMemory = searchOutput.memories.find((m: any) => m.hash === hash); + if (!updatedMemory) { + throw new Error('Updated memory not found with correct hash'); + } + + if (updatedMemory.content !== 'Updated content') { + throw new Error(`Content not updated correctly: ${updatedMemory.content}`); + } + + console.log('✓ Memory content updated successfully'); +} + +/** + * Test updating memory tags + */ +async function testUpdateMemoryTags(): Promise { + // Store a memory first + const storeResult = await executeCommand(['store-memory', '--content', 'Content for tag update', '--tags', 'old,tag']); + const storeOutput = parseJsonOutput(storeResult.stdout); + + if (!storeOutput?.success || !storeOutput.hash) { + throw new Error('Failed to store memory for tag update test'); + } + + const hash = storeOutput.hash; + console.log(` Stored memory with hash: ${formatHash(hash)}`); + + // Update the tags + const updateResult = await executeCommand(['update-memory', '--hash', hash, '--tags', 'new,updated,tag']); + + if (updateResult.exitCode !== 0) { + throw new Error(`Update memory tags failed: ${updateResult.stderr}`); + } + + const updateOutput = parseJsonOutput(updateResult.stdout); + if (!updateOutput?.success) { + throw new Error('Update memory tags did not return expected success response'); + } + + // Verify the update by searching with new tags + const searchResult = await executeCommand(['search-memory', '--tags', 'new']); + const searchOutput = parseJsonOutput(searchResult.stdout); + + if (!searchOutput?.memories || searchOutput.memories.length === 0) { + throw new Error('Updated memory not found in tag search'); + } + + const updatedMemory = searchOutput.memories.find((m: any) => m.hash === hash); + if (!updatedMemory) { + throw new Error('Updated memory not found with correct hash'); + } + + if (!updatedMemory.tags.includes('new') || !updatedMemory.tags.includes('updated')) { + throw new Error(`Tags not updated correctly: ${updatedMemory.tags.join(',')}`); + } + + // Verify old tag is gone + if (updatedMemory.tags.includes('old')) { + throw new Error('Old tag still present after update'); + } + + console.log('✓ Memory tags updated successfully'); +} + +/** + * Test updating both content and tags + */ +async function testUpdateMemoryBoth(): Promise { + // Store a memory first + const storeResult = await executeCommand(['store-memory', '--content', 'Original for both update', '--tags', 'original']); + const storeOutput = parseJsonOutput(storeResult.stdout); + + if (!storeOutput?.success || !storeOutput.hash) { + throw new Error('Failed to store memory for combined update test'); + } + + const hash = storeOutput.hash; + console.log(` Stored memory with hash: ${formatHash(hash)}`); + + // Update both content and tags + const updateResult = await executeCommand([ + 'update-memory', + '--hash', hash, + '--content', 'Updated both content and tags', + '--tags', 'combined,update' + ]); + + if (updateResult.exitCode !== 0) { + throw new Error(`Update memory (both) failed: ${updateResult.stderr}`); + } + + const updateOutput = parseJsonOutput(updateResult.stdout); + if (!updateOutput?.success) { + throw new Error('Update memory (both) did not return expected success response'); + } + + // Verify the update + const searchResult = await executeCommand(['search-memory', '--tags', 'combined']); + const searchOutput = parseJsonOutput(searchResult.stdout); + + if (!searchOutput?.memories || searchOutput.memories.length === 0) { + throw new Error('Updated memory not found in search'); + } + + const updatedMemory = searchOutput.memories.find((m: any) => m.hash === hash); + if (!updatedMemory) { + throw new Error('Updated memory not found with correct hash'); + } + + if (updatedMemory.content !== 'Updated both content and tags') { + throw new Error(`Content not updated correctly: ${updatedMemory.content}`); + } + + if (!updatedMemory.tags.includes('combined') || !updatedMemory.tags.includes('update')) { + throw new Error(`Tags not updated correctly: ${updatedMemory.tags.join(',')}`); + } + + console.log('✓ Memory content and tags updated successfully'); +} + +/** + * Test updating non-existent memory + */ +async function testUpdateNonExistentMemory(): Promise { + const fakeHash = 'nonexistent123456789abcdef'; + + const updateResult = await executeCommand(['update-memory', '--hash', fakeHash, '--content', 'Should fail']); + + if (updateResult.exitCode !== 0) { + throw new Error(`Update should return gracefully even for non-existent hash`); + } + + const updateOutput = parseJsonOutput(updateResult.stdout); + if (updateOutput?.success === true) { + throw new Error('Update should fail for non-existent memory'); + } + + if (!updateOutput?.message || !updateOutput.message.includes('not found')) { + throw new Error('Update should return "not found" message'); + } + + console.log('✓ Non-existent memory update handled correctly'); +} + /** * Main test runner */ @@ -393,6 +572,10 @@ async function runAllTests(): Promise { { name: 'Memory Statistics', fn: testMemoryStats }, { name: 'Integrated Relationships', fn: testIntegratedRelationships }, { name: 'Search with Relationships', fn: testSearchWithRelationships }, + { name: 'Update Memory Content', fn: testUpdateMemoryContent }, + { name: 'Update Memory Tags', fn: testUpdateMemoryTags }, + { name: 'Update Memory Both', fn: testUpdateMemoryBoth }, + { name: 'Update Non-Existent Memory', fn: testUpdateNonExistentMemory }, { name: 'Delete Memory by Tag', fn: testDeleteMemoryByTag }, { name: 'Search with Limit', fn: testSearchWithLimit }, { name: 'Improved Search (OR + BM25)', fn: testImprovedSearch } diff --git a/src/tools/index.ts b/src/tools/index.ts index 200fc93..ed07670 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import type { Tool, ToolDefinition, ToolContext } from '../types/tools.js'; import { storeMemoryTool } from './store-memory/index.js'; import { searchMemoryTool } from './search-memory/index.js'; import { deleteMemoryTool } from './delete-memory/index.js'; +import { updateMemoryTool } from './update-memory/index.js'; import { memoryStatsTool } from './memory-stats/index.js'; import { exportMemoryTool } from './export-memory/index.js'; import { importMemoryTool } from './import-memory/index.js'; @@ -18,6 +19,7 @@ export class ToolRegistry { this.registerTool(storeMemoryTool); this.registerTool(searchMemoryTool); this.registerTool(deleteMemoryTool); + this.registerTool(updateMemoryTool); this.registerTool(memoryStatsTool); this.registerTool(exportMemoryTool); this.registerTool(importMemoryTool); diff --git a/src/tools/update-memory/cli-parser.ts b/src/tools/update-memory/cli-parser.ts new file mode 100644 index 0000000..23c6c9c --- /dev/null +++ b/src/tools/update-memory/cli-parser.ts @@ -0,0 +1,32 @@ +import { parseCommandLineArgs } from '../../utils/cli-parser.js'; + +export function parseCliArgs(args: string[]) { + // PHASE 1: Convert string array to object (shared utility) + const rawArgs = parseCommandLineArgs(args); + + // PHASE 2: Validate and transform (command-specific logic) + const result: any = {}; + + if (rawArgs.hash) { + result.hash = rawArgs.hash; + } + + if (rawArgs.content) { + result.content = rawArgs.content; + } + + if (rawArgs.tags) { + result.tags = (rawArgs.tags as string).split(',').map((tag: string) => tag.trim()); + } + + // Validation + if (!result.hash) { + throw new Error('--hash is required'); + } + + if (!result.content && !result.tags) { + throw new Error('Must provide either --content or --tags to update'); + } + + return result; +} diff --git a/src/tools/update-memory/executor.ts b/src/tools/update-memory/executor.ts new file mode 100644 index 0000000..7ce0b78 --- /dev/null +++ b/src/tools/update-memory/executor.ts @@ -0,0 +1,58 @@ +import type { ToolContext } from '../../types/tools.js'; +import { debugLog, formatHash } from '../../utils/debug.js'; + +interface UpdateMemoryArgs { + hash: string; + content?: string; + tags?: string[]; +} + +interface UpdateMemoryResult { + success: boolean; + hash: string; + message: string; +} + +export async function execute(args: UpdateMemoryArgs, context: ToolContext): Promise { + try { + // Validate hash + if (!args.hash || args.hash.trim().length === 0) { + throw new Error('Hash cannot be empty'); + } + + // Validate that at least one field is being updated + if (!args.content && !args.tags) { + throw new Error('Must provide either content or tags to update'); + } + + // Log content size for large memories + if (args.content) { + const contentSize = args.content.length; + if (contentSize > 100000) { + debugLog(`Updating with large content: ${contentSize} characters`); + } + } + + const updated = context.memoryService.update(args.hash, args.content, args.tags); + + if (!updated) { + return { + success: false, + hash: args.hash, + message: `Memory with hash ${formatHash(args.hash)} not found` + }; + } + + return { + success: true, + hash: args.hash, + message: `Memory updated successfully with hash: ${formatHash(args.hash)}` + }; + } catch (error) { + return { + success: false, + hash: args.hash || '', + message: `Failed to update memory: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} diff --git a/src/tools/update-memory/index.ts b/src/tools/update-memory/index.ts new file mode 100644 index 0000000..2ef4d5a --- /dev/null +++ b/src/tools/update-memory/index.ts @@ -0,0 +1,58 @@ +import type { Tool } from '../../types/tools.js'; +import { execute } from './executor.js'; +import { parseCliArgs } from './cli-parser.js'; + +export const updateMemoryTool: Tool = { + definition: { + name: 'update-memory', + description: 'Update an existing memory\'s content and/or tags. Use this to refine, correct, or enhance stored information instead of creating duplicates.', + inputSchema: { + type: 'object', + properties: { + hash: { + type: 'string', + description: 'The hash of the memory to update (required)' + }, + content: { + type: 'string', + description: 'New content to replace existing content (optional if tags provided)' + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'New tags to replace existing tags (optional if content provided)' + } + }, + required: ['hash'] + } + }, + handler: execute, + cliParser: parseCliArgs, + cliMetadata: { + options: [ + { + name: '--hash', + description: 'Hash of the memory to update', + hasValue: true, + example: '--hash abc123...' + }, + { + name: '--content', + description: 'New content for the memory', + hasValue: true, + example: '--content "Updated information"' + }, + { + name: '--tags', + description: 'New comma-separated tags', + hasValue: true, + example: '--tags "tag1,tag2,tag3"' + } + ], + examples: [ + 'update-memory --hash abc123... --content "Corrected information"', + 'update-memory --hash abc123... --tags "new,tags"', + 'update-memory --hash abc123... --content "Updated" --tags "updated,info"' + ] + } +};