diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4dc6e..53c3880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **ID-based Search and Export**: Quick batch operations using memory IDs + - `search-memory --ids "1,5,10"` - Search for specific memories by ID + - `export-memory --ids "1,2,3"` - Export specific memories by ID + - Useful for LLM batch operations when query/tag filters don't match all desired memories + - IDs can be obtained from previous search results + - **Automated Version Bumping**: GitHub Actions workflow automatically bumps patch version on every commit/merge to main branch - Uses existing `npm run build:release` command - Commits changes back to repository with `[skip-version]` tag diff --git a/README.md b/README.md index 5bea311..d372319 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,9 @@ simple-memory search-memory --query "search term" # Search by tags simple-memory search-memory --tags "tag1,tag2" +# Search by specific IDs (useful for batch operations) +simple-memory search-memory --ids "1,5,10" + # Search with relevance filtering (0-1 scale) simple-memory search-memory --query "architecture" --min-relevance 0.7 diff --git a/package-lock.json b/package-lock.json index 30e179a..5f638cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "simple-memory-mcp", - "version": "1.0.16", + "version": "1.0.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simple-memory-mcp", - "version": "1.0.16", + "version": "1.0.19", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.3", diff --git a/src/services/memory-service.ts b/src/services/memory-service.ts index e1111e6..f67dee2 100644 --- a/src/services/memory-service.ts +++ b/src/services/memory-service.ts @@ -342,12 +342,41 @@ export class MemoryService { } } + /** + * Search memories by specific IDs + * Private helper method for ID-based search + */ + private searchByIds(ids: number[]): any[] { + if (!this.db) { + throw new Error('Database not initialized'); + } + + const placeholders = ids.map(() => '?').join(','); + const stmt = this.db.prepare(` + SELECT * FROM memories + WHERE id IN (${placeholders}) + ORDER BY created_at DESC + `); + + const idResults = stmt.all(...ids); + + // Hydrate with tags + return idResults.map((row: any) => { + const tagRows = this.stmts.getTagsForMemory.all(row.id) as Array<{ tag: string }>; + return { + ...row, + tags: tagRows.map(t => t.tag) + }; + }); + } + /** * Search memories by content or tags */ search( query?: string, - tags?: string[], + tags?: string[], + ids?: number[], limit: number = 10, daysAgo?: number, startDate?: string, @@ -392,7 +421,10 @@ export class MemoryService { } } - if (query) { + if (ids && ids.length > 0) { + // Search by specific IDs + results = this.searchByIds(ids); + } else if (query) { // Use FTS for text search let ftsResults: any[]; // Tokenize query into words and join with OR for flexible matching @@ -869,8 +901,9 @@ export class MemoryService { // Use existing search method to get memories // Pass undefined for query to use tag search (if tags provided) or recent search (if no filters) const memories = this.search( - undefined, // query - let search decide based on tags + undefined, // query - let search decide based on tags/ids filters?.tags, + filters?.ids, filters?.limit || 1000, // default high limit for export undefined, // daysAgo filters?.startDate?.toISOString(), diff --git a/src/tests/export-import-test.ts b/src/tests/export-import-test.ts index 5e804f3..a585379 100644 --- a/src/tests/export-import-test.ts +++ b/src/tests/export-import-test.ts @@ -174,7 +174,7 @@ async function runTests() { assert(importResult.errors.length === 0, 'No import errors'); // Verify imported data - const importedMemories = importService.search(undefined, undefined, 10); + const importedMemories = importService.search(undefined, undefined, undefined, 10); assert(importedMemories.length === 5, 'All 5 memories are searchable'); // ========================================== @@ -197,7 +197,7 @@ async function runTests() { console.log('\n[TEST 8] Verify relationship preservation'); // Check if relationships were restored - const memoriesWithRelationships = importService.search('TypeScript', undefined, 10); + const memoriesWithRelationships = importService.search('TypeScript', undefined, undefined, 10); if (memoriesWithRelationships.length > 0) { const related = importService.getRelated(memoriesWithRelationships[0].hash, 5); console.log(` Found ${related.length} related memories (relationships preserved)`); @@ -224,7 +224,7 @@ async function runTests() { assert(importFilteredResult.success === true, 'Filtered import succeeded'); assert(importFilteredResult.imported === 3, 'Imported 3 work memories'); - const workMemories = importFilteredService.search(undefined, ['work'], 10); + const workMemories = importFilteredService.search(undefined, ['work'], undefined, 10); assert(workMemories.length === 3, 'All imported memories have work tag'); importFilteredService.close(); diff --git a/src/tests/memory-server-tests.ts b/src/tests/memory-server-tests.ts index 4575593..0e7164c 100644 --- a/src/tests/memory-server-tests.ts +++ b/src/tests/memory-server-tests.ts @@ -508,6 +508,107 @@ async function testUpdateNonExistentMemory(): Promise { console.log(`✓ Update correctly handled non-existent memory`); } +/** + * Test search by IDs + */ +async function testSearchByIds(): Promise { + // First, store some memories and capture their IDs + const memory1Result = await executeCommand(['store-memory', '--content', 'Memory with ID 1', '--tags', 'test-id']); + const memory1Output = parseJsonOutput(memory1Result.stdout); + + const memory2Result = await executeCommand(['store-memory', '--content', 'Memory with ID 2', '--tags', 'test-id']); + const memory2Output = parseJsonOutput(memory2Result.stdout); + + const memory3Result = await executeCommand(['store-memory', '--content', 'Memory with ID 3', '--tags', 'test-id']); + const memory3Output = parseJsonOutput(memory3Result.stdout); + + // Get all memories to find their IDs + const allResult = await executeCommand(['search-memory', '--tags', 'test-id']); + const allOutput = parseJsonOutput(allResult.stdout); + + if (!allOutput?.memories || allOutput.memories.length < 3) { + throw new Error('Failed to store test memories'); + } + + // Get the IDs of the first and third memories + const id1 = allOutput.memories.find((m: any) => m.content === 'Memory with ID 1')?.id; + const id3 = allOutput.memories.find((m: any) => m.content === 'Memory with ID 3')?.id; + + if (!id1 || !id3) { + throw new Error('Could not find memory IDs'); + } + + // Search by specific IDs + const searchResult = await executeCommand(['search-memory', '--ids', `${id1},${id3}`]); + const searchOutput = parseJsonOutput(searchResult.stdout); + + if (!searchOutput?.memories || searchOutput.memories.length !== 2) { + throw new Error(`Expected 2 memories, got ${searchOutput?.memories?.length || 0}`); + } + + const foundIds = searchOutput.memories.map((m: any) => m.id); + if (!foundIds.includes(id1) || !foundIds.includes(id3)) { + throw new Error('Did not find expected memories by ID'); + } + + console.log(`✓ Found ${searchOutput.memories.length} memories by IDs`); + console.log(`✓ Verified IDs match requested IDs: ${id1}, ${id3}`); +} + +/** + * Test export by IDs + */ +async function testExportByIds(): Promise { + // First, store some memories + await executeCommand(['store-memory', '--content', 'Export test memory 1', '--tags', 'export-test']); + await executeCommand(['store-memory', '--content', 'Export test memory 2', '--tags', 'export-test']); + await executeCommand(['store-memory', '--content', 'Export test memory 3', '--tags', 'export-test']); + + // Get all memories to find their IDs + const allResult = await executeCommand(['search-memory', '--tags', 'export-test']); + const allOutput = parseJsonOutput(allResult.stdout); + + if (!allOutput?.memories || allOutput.memories.length < 3) { + throw new Error('Failed to store test memories for export'); + } + + // Get the IDs of first two memories + const id1 = allOutput.memories[0].id; + const id2 = allOutput.memories[1].id; + + // Export by specific IDs + const exportPath = './test-export-by-ids.json'; + const exportResult = await executeCommand(['export-memory', '--output', exportPath, '--ids', `${id1},${id2}`]); + const exportOutput = parseJsonOutput(exportResult.stdout); + + if (!exportOutput?.success) { + throw new Error('Export by IDs failed'); + } + + if (exportOutput.totalMemories !== 2) { + throw new Error(`Expected 2 exported memories, got ${exportOutput.totalMemories}`); + } + + // Verify the exported file contains the correct memories + const { readFileSync, unlinkSync } = await import('fs'); + const exportData = JSON.parse(readFileSync(exportPath, 'utf-8')); + + if (exportData.memories.length !== 2) { + throw new Error(`Expected 2 memories in export file, got ${exportData.memories.length}`); + } + + const exportedIds = exportData.memories.map((m: any) => m.id); + if (!exportedIds.includes(id1) || !exportedIds.includes(id2)) { + throw new Error('Exported memories do not match requested IDs'); + } + + // Clean up + unlinkSync(exportPath); + + console.log(`✓ Exported ${exportOutput.totalMemories} memories by IDs`); + console.log(`✓ Verified exported memories match requested IDs`); +} + /** * Main test runner */ @@ -531,7 +632,9 @@ async function runAllTests(): Promise { { 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 } + { name: 'Improved Search (OR + BM25)', fn: testImprovedSearch }, + { name: 'Search by IDs', fn: testSearchByIds }, + { name: 'Export by IDs', fn: testExportByIds } ]; const results: TestResult[] = []; diff --git a/src/tests/migration-test.ts b/src/tests/migration-test.ts index 4d9f56c..d5b12b1 100644 --- a/src/tests/migration-test.ts +++ b/src/tests/migration-test.ts @@ -89,21 +89,21 @@ function testMigration(): void { // Test 1: Verify all memories are accessible console.log('📊 Test 1: Verify all memories accessible'); - const allMemories = service.search('', [], 10); + const allMemories = service.search('', [], undefined, 10); assert.strictEqual(allMemories.length, 5, `Expected 5 memories, got ${allMemories.length}`); console.log(`✅ All 5 memories accessible\n`); // Test 2: Verify tag normalization console.log('📊 Test 2: Verify tag normalization and indexing'); - const typescriptResults = service.search(undefined, ['typescript'], 10); + const typescriptResults = service.search(undefined, ['typescript'], undefined, 10); assert.strictEqual(typescriptResults.length, 2, `Expected 2 memories with 'typescript' tag, got ${typescriptResults.length}`); console.log(`✅ Found ${typescriptResults.length} memories with 'typescript' tag`); - const testingResults = service.search(undefined, ['testing'], 10); + const testingResults = service.search(undefined, ['testing'], undefined, 10); assert.strictEqual(testingResults.length, 2, `Expected 2 memories with 'testing' tag, got ${testingResults.length}`); console.log(`✅ Found ${testingResults.length} memories with 'testing' tag`); - const optimizationResults = service.search(undefined, ['optimization'], 10); + const optimizationResults = service.search(undefined, ['optimization'], undefined, 10); assert.strictEqual(optimizationResults.length, 2, `Expected 2 memories with 'optimization' tag, got ${optimizationResults.length}`); console.log(`✅ Found ${optimizationResults.length} memories with 'optimization' tag\n`); @@ -138,14 +138,14 @@ function testMigration(): void { // Test 7: Verify FTS search still works console.log('📊 Test 7: Verify FTS search functionality'); - const ftsResults = service.search('TypeScript', undefined, 10); + const ftsResults = service.search('TypeScript', undefined, undefined, 10); assert(ftsResults.length >= 2, `Expected at least 2 FTS results, got ${ftsResults.length}`); console.log(`✅ FTS search working: ${ftsResults.length} results\n`); // Test 8: Verify new memories work with migrated schema console.log('📊 Test 8: Verify new memory insertion'); const newHash = service.store('New memory after migration', ['migration', 'test']); - const newMemory = service.search(undefined, ['migration'], 10); + const newMemory = service.search(undefined, ['migration'], undefined, 10); assert(newMemory.length >= 1, 'New memory should be findable by tag'); console.log(`✅ New memory insertion works\n`); @@ -240,7 +240,7 @@ function testPartialMigrationRecovery(): void { assert.strictEqual(migrationsAfter.length, 3, `Expected 3 migrations, found ${migrationsAfter.length}`); // Verify tags table exists and data migrated - const tagResults = service.search(undefined, ['recovery'], 10); + const tagResults = service.search(undefined, ['recovery'], undefined, 10); assert.strictEqual(tagResults.length, 1, 'Should find memory by migrated tag'); console.log('✅ Partial migration recovery successful\n'); diff --git a/src/tests/performance-benchmark.ts b/src/tests/performance-benchmark.ts index cb829dd..ad69ff5 100644 --- a/src/tests/performance-benchmark.ts +++ b/src/tests/performance-benchmark.ts @@ -97,13 +97,13 @@ async function runBenchmarks() { } const tagSearchResult = benchmark('Search by tag (indexed)', 1000, () => { - service.search(undefined, ['search'], 10); + service.search(undefined, ['search'], undefined, 10); }); results.push(tagSearchResult); console.log(` Indexed tag query: ${tagSearchResult.avgMs.toFixed(2)}ms avg (${tagSearchResult.opsPerSecond} ops/sec)`); const multiTagResult = benchmark('Search by multiple tags', 1000, () => { - service.search(undefined, ['test'], 10); + service.search(undefined, ['test'], undefined, 10); }); results.push(multiTagResult); console.log(` Multiple results: ${multiTagResult.avgMs.toFixed(2)}ms avg (${multiTagResult.opsPerSecond} ops/sec)`); @@ -128,13 +128,13 @@ async function runBenchmarks() { const ftsResults = [ benchmark('FTS: simple query', 1000, () => { - service.search('quick brown', undefined, 10); + service.search('quick brown', undefined, undefined, 10); }), benchmark('FTS: complex query', 1000, () => { - service.search('machine learning intelligence', undefined, 10); + service.search('machine learning intelligence', undefined, undefined, 10); }), benchmark('FTS: common word', 1000, () => { - service.search('performance', undefined, 10); + service.search('performance', undefined, undefined, 10); }) ]; diff --git a/src/tests/time-range-test.ts b/src/tests/time-range-test.ts index 87deb89..3704622 100644 --- a/src/tests/time-range-test.ts +++ b/src/tests/time-range-test.ts @@ -63,7 +63,7 @@ async function runTests() { // Test 1: Search memories from TODAY ONLY (daysAgo=0) - Critical UTC edge case console.log('Running: Search today only (daysAgo=0)...'); - const todayOnly = service.search(undefined, undefined, 10, 0); + const todayOnly = service.search(undefined, undefined, undefined, 10, 0); if (todayOnly.length === 1 && todayOnly[0].content === 'Memory from today') { console.log(`✓ Found ${todayOnly.length} memory from today (UTC boundary test passed)`); console.log(` - ${todayOnly[0].content}`); @@ -78,7 +78,7 @@ async function runTests() { // Test 2: Search memories from last 2 days console.log('Running: Search last 2 days...'); - const last2Days = service.search(undefined, undefined, 10, 2); + const last2Days = service.search(undefined, undefined, undefined, 10, 2); if (last2Days.length === 2) { console.log(`✓ Found ${last2Days.length} memories from last 2 days`); console.log(` - ${last2Days.map(m => m.content).join('\n - ')}`); @@ -91,7 +91,7 @@ async function runTests() { // Test 3: Search memories from last 10 days console.log('Running: Search last 10 days...'); - const last10Days = service.search(undefined, undefined, 10, 10); + const last10Days = service.search(undefined, undefined, undefined, 10, 10); if (last10Days.length === 3) { console.log(`✓ Found ${last10Days.length} memories from last 10 days`); console.log(` - ${last10Days.map(m => m.content).join('\n - ')}`); @@ -104,7 +104,7 @@ async function runTests() { // Test 4: Search memories from last 40 days console.log('Running: Search last 40 days...'); - const last40Days = service.search(undefined, undefined, 10, 40); + const last40Days = service.search(undefined, undefined, undefined, 10, 40); if (last40Days.length === 4) { console.log(`✓ Found ${last40Days.length} memories from last 40 days`); passed++; @@ -122,7 +122,8 @@ async function runTests() { endDate.setDate(endDate.getDate() - 25); const dateRange = service.search( undefined, - undefined, + undefined, + undefined, 10, undefined, startDate.toISOString().split('T')[0], // Use YYYY-MM-DD format @@ -140,7 +141,7 @@ async function runTests() { // Test 6: Search with content query + time range console.log('Running: Search with query and time range...'); - const queryAndTime = service.search('Memory', undefined, 10, 10); + const queryAndTime = service.search('Memory', undefined, undefined, 10, 10); if (queryAndTime.length === 3) { console.log(`✓ Found ${queryAndTime.length} memories matching query within time range`); passed++; @@ -152,7 +153,7 @@ async function runTests() { // Test 7: Search with tags + time range console.log('Running: Search with tags and time range...'); - const tagsAndTime = service.search(undefined, ['old'], 10, 40); + const tagsAndTime = service.search(undefined, ['old'], undefined, 10, 40); if (tagsAndTime.length === 2) { console.log(`✓ Found ${tagsAndTime.length} memories with tag within time range`); passed++; diff --git a/src/tools/export-memory/cli-parser.ts b/src/tools/export-memory/cli-parser.ts index 8630af7..ee84371 100644 --- a/src/tools/export-memory/cli-parser.ts +++ b/src/tools/export-memory/cli-parser.ts @@ -14,6 +14,17 @@ export function parseCliArgs(args: string[]) { if (rawArgs.tags) { result.tags = (rawArgs.tags as string).split(',').map((tag: string) => tag.trim()); } + + if (rawArgs.ids) { + // Handle both string (comma-separated) and number (single value) + if (typeof rawArgs.ids === 'string') { + result.ids = rawArgs.ids.split(',').map((id: string) => parseInt(id.trim(), 10)); + } else if (typeof rawArgs.ids === 'number') { + result.ids = [rawArgs.ids]; + } else if (Array.isArray(rawArgs.ids)) { + result.ids = rawArgs.ids.map((id: any) => typeof id === 'number' ? id : parseInt(id, 10)); + } + } if (rawArgs.daysAgo !== undefined) { result.daysAgo = rawArgs.daysAgo; // Already a number diff --git a/src/tools/export-memory/executor.ts b/src/tools/export-memory/executor.ts index 7ad9cea..3f374c2 100644 --- a/src/tools/export-memory/executor.ts +++ b/src/tools/export-memory/executor.ts @@ -18,6 +18,10 @@ export async function execute(args: any, context: ToolContext): Promise 0) { filters.tags = args.tags; } + + if (args.ids && args.ids.length > 0) { + filters.ids = args.ids; + } if (args.daysAgo !== undefined) { const date = new Date(); diff --git a/src/tools/export-memory/index.ts b/src/tools/export-memory/index.ts index 8689ca1..1c18128 100644 --- a/src/tools/export-memory/index.ts +++ b/src/tools/export-memory/index.ts @@ -18,6 +18,11 @@ export const exportMemoryTool: Tool = { items: { type: 'string' }, description: 'Filter by tags (optional)' }, + ids: { + type: 'array', + items: { type: 'number' }, + description: 'Filter by specific memory IDs (optional)' + }, daysAgo: { type: 'number', description: 'Export memories from the last N days' @@ -58,6 +63,12 @@ export const exportMemoryTool: Tool = { hasValue: true, example: '--tags "work,project"' }, + { + name: '--ids', + description: 'Filter by comma-separated memory IDs', + hasValue: true, + example: '--ids "1,2,3"' + }, { name: '--days-ago', description: 'Export memories from last N days', @@ -92,6 +103,7 @@ export const exportMemoryTool: Tool = { examples: [ 'export-memory --output all.json', 'export-memory --output work.json --tags "work,project"', + 'export-memory --output batch.json --ids "1,5,10"', 'export-memory --output recent.json --start-date 2025-10-01 --limit 20', 'export-memory --output lastweek.json --days-ago 7' ] diff --git a/src/tools/search-memory/cli-parser.ts b/src/tools/search-memory/cli-parser.ts index bfd7505..5f03dbf 100644 --- a/src/tools/search-memory/cli-parser.ts +++ b/src/tools/search-memory/cli-parser.ts @@ -16,6 +16,16 @@ export function parseCliArgs(args: string[]) { if (rawArgs.tags) { result.tags = (rawArgs.tags as string).split(',').map((tag: string) => tag.trim()); } + if (rawArgs.ids) { + // Handle both string (comma-separated) and number (single value) + if (typeof rawArgs.ids === 'string') { + result.ids = rawArgs.ids.split(',').map((id: string) => parseInt(id.trim(), 10)); + } else if (typeof rawArgs.ids === 'number') { + result.ids = [rawArgs.ids]; + } else if (Array.isArray(rawArgs.ids)) { + result.ids = rawArgs.ids.map((id: any) => typeof id === 'number' ? id : parseInt(id, 10)); + } + } if (rawArgs.limit) { result.limit = rawArgs.limit; // Already a number } diff --git a/src/tools/search-memory/executor.ts b/src/tools/search-memory/executor.ts index f3ac12f..d2bb2c1 100644 --- a/src/tools/search-memory/executor.ts +++ b/src/tools/search-memory/executor.ts @@ -4,6 +4,7 @@ import type { MemoryEntry } from '../../services/memory-service.js'; interface SearchMemoryArgs { query?: string; tags?: string[]; + ids?: number[]; limit?: number; includeRelated?: boolean; relationshipDepth?: number; @@ -28,7 +29,8 @@ export async function execute(args: SearchMemoryArgs, context: ToolContext): Pro const memories = context.memoryService.search( args.query, - args.tags, + args.tags, + args.ids, limit, args.daysAgo, args.startDate, diff --git a/src/tools/search-memory/index.ts b/src/tools/search-memory/index.ts index 1b11c3f..6b59579 100644 --- a/src/tools/search-memory/index.ts +++ b/src/tools/search-memory/index.ts @@ -29,6 +29,11 @@ Search silently and incorporate findings naturally into responses.`, items: { type: 'string' }, description: 'Array of tags to filter results by. Matches memories that have these exact tags.' }, + ids: { + type: 'array', + items: { type: 'number' }, + description: 'Array of memory IDs to retrieve. Useful for batch operations when you have specific memory IDs.' + }, limit: { type: 'number', description: 'Maximum number of results to return', diff --git a/src/tools/store-memory/executor.ts b/src/tools/store-memory/executor.ts index 23bc12f..4682480 100644 --- a/src/tools/store-memory/executor.ts +++ b/src/tools/store-memory/executor.ts @@ -70,7 +70,7 @@ async function createAutoRelationships(hash: string, args: StoreMemoryArgs, cont try { // Find memories with similar tags - const similarMemories = context.memoryService.search(undefined, args.tags, 5); + const similarMemories = context.memoryService.search(undefined, args.tags, undefined, 5); // Build array of relationships to create const relationships = similarMemories @@ -103,7 +103,7 @@ async function createAutoRelationships(hash: string, args: StoreMemoryArgs, cont async function createExplicitRelationships(hash: string, relateTo: string[], context: ToolContext): Promise { try { // Find memories with the specified tags - const relatedMemories = context.memoryService.search(undefined, relateTo, 10); + const relatedMemories = context.memoryService.search(undefined, relateTo, undefined, 10); // Build array of relationships to create const relationships = relatedMemories diff --git a/src/types/tools.ts b/src/types/tools.ts index e866cde..93b310a 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -51,6 +51,7 @@ export interface Tool { // Export/Import feature types export interface ExportFilters { tags?: string[]; + ids?: number[]; startDate?: Date; endDate?: Date; limit?: number; diff --git a/src/web-server.ts b/src/web-server.ts index 8a1a430..13a1f2d 100644 --- a/src/web-server.ts +++ b/src/web-server.ts @@ -163,6 +163,7 @@ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => const memories = memoryService.search( query, tag ? [tag] : undefined, + undefined, limit );