Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 36 additions & 3 deletions src/services/memory-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions src/tests/export-import-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

// ==========================================
Expand All @@ -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)`);
Expand All @@ -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();
Expand Down
105 changes: 104 additions & 1 deletion src/tests/memory-server-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,107 @@ async function testUpdateNonExistentMemory(): Promise<void> {
console.log(`✓ Update correctly handled non-existent memory`);
}

/**
* Test search by IDs
*/
async function testSearchByIds(): Promise<void> {
// 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<void> {
// 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
*/
Expand All @@ -531,7 +632,9 @@ async function runAllTests(): Promise<void> {
{ 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[] = [];
Expand Down
14 changes: 7 additions & 7 deletions src/tests/migration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);

Expand Down Expand Up @@ -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`);

Expand Down Expand Up @@ -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');
Expand Down
10 changes: 5 additions & 5 deletions src/tests/performance-benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`);
Expand All @@ -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);
})
];

Expand Down
15 changes: 8 additions & 7 deletions src/tests/time-range-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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 - ')}`);
Expand All @@ -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 - ')}`);
Expand All @@ -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++;
Expand All @@ -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
Expand All @@ -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++;
Expand All @@ -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++;
Expand Down
Loading