Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .github/workflows/user-isolation-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ jobs:
# Note: ANTHROPIC_API_KEY not needed - test messages are tiny, no summarization triggered
run: node tests/user-isolation.test.js

- name: Run Tag Filtering Test
env:
OPENMEMORY_URL: http://localhost:8080
OPENMEMORY_API_KEY: test-ci-key-for-isolation-testing
run: node tests/tag-filtering.test.js

- name: Run Large Message Replacement Test
env:
OPENMEMORY_URL: http://localhost:8080
OPENMEMORY_API_KEY: test-ci-key-for-isolation-testing
ANTHROPIC_API_KEY: test-key-not-needed
run: node tests/large-message-replacement.test.js

- name: Show OpenMemory logs (if test failed)
if: failure()
run: docker logs openmemory
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "infinite-memory",
"version": "0.1.5",
"version": "0.1.6",
"description": "Infinite context windows for Claude via OpenMemory semantic retrieval",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -9,7 +9,9 @@
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"test:isolation": "node tests/user-isolation.test.js"
"test:isolation": "node tests/user-isolation.test.js",
"test:tag-filtering": "node tests/tag-filtering.test.js",
"test:large-message": "node tests/large-message-replacement.test.js"
},
"keywords": [
"claude",
Expand Down
75 changes: 73 additions & 2 deletions src/ContextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ import { extractSearchableText } from './utils/messageFormatter.js';
import { getModelLimit } from './types.js';
import { summarizeConversation, summarizeLargeMessage } from './utils/summarizer.js';

/**
* Extract base message ID from chunk IDs like "uuid-chunk-1" -> "uuid"
*/
function extractBaseMessageId(id: string): string {
const match = id.match(/^(.+)-chunk-\d+$/);
return match ? match[1] : id;
}

/**
* Create a placeholder for large stored messages
*/
function createMemoryPlaceholder(messageId: string, content: string | any[]): string {
const preview = extractSearchableText({ role: 'user', content })
.substring(0, 500);

return `<LARGE_MESSAGE_IN_MEMORY id="${messageId}">
This message has been stored in your long-term memory. To recall details, search your memory.

Preview: "${preview}..."
</LARGE_MESSAGE_IN_MEMORY>`;
}

export class ContextManager {
constructor(
private openMemory: OpenMemoryClient,
Expand Down Expand Up @@ -58,9 +80,58 @@ export class ContextManager {
`🎯 [InfiniteMemory] Context budget: ${inputBudget.toLocaleString()} tokens (model: ${modelId})`
);

// Check if any large messages have already been stored in OpenMemory
// Only check messages >50k tokens to avoid unnecessary queries
const largeMessages = messages.filter(msg => {
const tokens = estimateTotalTokens([msg]);
return tokens > 50000; // ~200k chars
});

let storedMessageIds = new Set<string>();
if (largeMessages.length > 0) {
console.log(`🔍 [InfiniteMemory] Checking if ${largeMessages.length} large messages are already stored...`);

const largeMessageIds = largeMessages
.map(msg => (msg as any).id)
.filter(Boolean);

if (largeMessageIds.length > 0) {
storedMessageIds = await this.openMemory.checkMessagesExist(
context.userId,
largeMessageIds
);
console.log(`✅ [InfiniteMemory] Found ${storedMessageIds.size} large messages already in memory`);
}
}

// Replace large stored messages with placeholders to prevent re-processing
const processedMessages = messages.map(msg => {
const msgId = (msg as any).id;
if (!msgId) return msg;

const tokens = estimateTotalTokens([msg]);
const isLarge = tokens > 50000;

if (!isLarge) return msg; // Small messages pass through

// Check if this message (or its chunks) has been stored
const baseId = extractBaseMessageId(msgId);
const isStored = storedMessageIds.has(msgId) || storedMessageIds.has(baseId);

if (isStored) {
console.log(`🔄 [InfiniteMemory] Replacing large stored message ${msgId} with memory placeholder`);
return {
...msg,
content: createMemoryPlaceholder(msgId, msg.content)
} as CoreMessage;
}

return msg;
}) as CoreMessage[];

// Always include the last 3-5 messages chronologically
const recentCount = Math.min(5, messages.length);
const recentMessages = messages.slice(-recentCount);
const recentCount = Math.min(5, processedMessages.length);
const recentMessages = processedMessages.slice(-recentCount);
const recentTokens = estimateTotalTokens(recentMessages);

console.log(
Expand Down
40 changes: 38 additions & 2 deletions src/OpenMemoryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export class OpenMemoryClient {
async addMessage(message: StoredMessage): Promise<void> {
try {
// Extract searchable text for embedding
// Pass the full message content (handles both string and object formats)
const searchableText = extractSearchableText(message.content);
// Pass the full message object (extractSearchableText expects message.content)
const searchableText = extractSearchableText(message);

// Validate that we have actual content to store
if (!searchableText || searchableText.trim().length === 0) {
Expand Down Expand Up @@ -90,6 +90,7 @@ export class OpenMemoryClient {
'chunk',
message.role,
message.conversationId, // Keep conversation in tags for filtering
message.id, // Message ID for exact filtering
],
metadata: {
timestamp: message.timestamp,
Expand Down Expand Up @@ -119,6 +120,7 @@ export class OpenMemoryClient {
'message',
message.role,
message.conversationId, // Keep conversation in tags for filtering
message.id, // Message ID for exact filtering
],
metadata: {
timestamp: message.timestamp,
Expand Down Expand Up @@ -147,6 +149,40 @@ export class OpenMemoryClient {
}
}

/**
* Check if messages exist in OpenMemory by their IDs
* Used to avoid re-processing already-stored messages
* Uses tag filtering for exact matching (not semantic)
*/
async checkMessagesExist(
userId: string,
messageIds: string[]
): Promise<Set<string>> {
const foundIds = new Set<string>();

for (const id of messageIds) {
try {
// Use empty query with tag filter for exact matching
const result = await this.client.query('', {
k: 1,
filters: {
user_id: `${userId}-user`, // Only check user messages
tags: [id], // Exact tag match on message ID
}
});

if (result.matches.length > 0) {
foundIds.add(id);
}
} catch (error) {
// Message doesn't exist or query failed, skip
continue;
}
}

return foundIds;
}

/**
* Query for relevant messages from both user and assistant
*/
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,10 @@ export function createInfiniteMemory(config: InfiniteMemoryConfig) {
getModel(modelId: string) {
return anthropic(modelId);
},

/**
* Internal OpenMemory client (exposed for testing)
*/
openMemory: openMemoryClient,
};
}
169 changes: 169 additions & 0 deletions tests/large-message-replacement.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Large Message Replacement Test
*
* Verifies that large messages (>50k tokens) that have been stored
* are replaced with memory placeholders on subsequent turns.
*
* Tests:
* 1. Store a large message (>50k tokens)
* 2. Request context with that same large message
* 3. Verify the large message is replaced with a placeholder
* 4. Verify the placeholder contains the message ID and preview
* 5. Verify small messages (<50k tokens) are NOT replaced
*
* This prevents "prompt too long" errors on follow-up messages.
*/

import { createInfiniteMemory } from '../dist/index.js';

async function testLargeMessageReplacement() {
console.log('🧪 Testing Large Message Replacement\n');

const memory = createInfiniteMemory({
openMemoryUrl: process.env.OPENMEMORY_URL || 'http://localhost:8080',
openMemoryApiKey: process.env.OPENMEMORY_API_KEY,
anthropicApiKey: process.env.ANTHROPIC_API_KEY || 'test-key',
openMemoryTimeout: 10000, // Longer timeout for large messages
});

const userId = `test-user-${Date.now()}`;
const conversationId = `conv-${Date.now()}`;
const largeMessageId = `large-msg-${Date.now()}`;
const smallMessageId = `small-msg-${Date.now()}`;

// Create a large message (>50k tokens ~= >200k chars)
const largeContent = 'A'.repeat(250000); // ~250k chars = ~62.5k tokens
console.log(`📝 Creating large message: ${largeContent.length} chars (~${Math.ceil(largeContent.length / 4)} tokens)`);

// Create a small message
const smallContent = 'This is a small message';
console.log(`📝 Creating small message: ${smallContent.length} chars\n`);

// Step 1: Store the large message
console.log('Step 1: Storing large message...');
await memory.storeMessage(conversationId, userId, 'user', largeContent, largeMessageId);
console.log('✅ Large message stored\n');

// Wait for indexing
console.log('⏳ Waiting 3s for indexing...');
await new Promise(resolve => setTimeout(resolve, 3000));

// Step 2: Also store the small message
console.log('Step 2: Storing small message...');
await memory.storeMessage(conversationId, userId, 'user', smallContent, smallMessageId);
console.log('✅ Small message stored\n');

await new Promise(resolve => setTimeout(resolve, 1000));

// Step 3: Request context with both messages (simulating a follow-up turn)
console.log('Step 3: Requesting context with both messages...');
const messages = [
{
role: 'user',
content: largeContent,
id: largeMessageId
},
{
role: 'user',
content: smallContent,
id: smallMessageId
},
];

const result = await memory.getRelevantContext(
conversationId,
userId,
messages,
'claude-sonnet-4-20250514'
);

console.log('\n📊 Results:');
console.log(` - Messages returned: ${result.messages.length}`);

// Step 4: Verify the large message was replaced
const processedLargeMessage = result.messages.find(m => m.id === largeMessageId);
const processedSmallMessage = result.messages.find(m => m.id === smallMessageId);

let testsPassed = 0;
let testsFailed = 0;

// Test 1: Large message should be replaced with placeholder
console.log('\n🧪 Test 1: Large message replaced with placeholder');
if (processedLargeMessage &&
typeof processedLargeMessage.content === 'string' &&
processedLargeMessage.content.includes('<LARGE_MESSAGE_IN_MEMORY')) {
console.log(' ✅ PASS - Large message replaced with placeholder');
testsPassed++;
} else {
console.error(' ❌ FAIL - Large message not replaced');
console.error(' Content:', processedLargeMessage?.content?.substring(0, 100));
testsFailed++;
}

// Test 2: Placeholder should contain message ID
console.log('\n🧪 Test 2: Placeholder contains message ID');
if (processedLargeMessage?.content?.includes(`id="${largeMessageId}"`)) {
console.log(' ✅ PASS - Placeholder contains message ID');
testsPassed++;
} else {
console.error(' ❌ FAIL - Message ID not in placeholder');
testsFailed++;
}

// Test 3: Placeholder should contain preview
console.log('\n🧪 Test 3: Placeholder contains preview text');
if (processedLargeMessage?.content?.includes('Preview:')) {
console.log(' ✅ PASS - Placeholder contains preview');
testsPassed++;
} else {
console.error(' ❌ FAIL - No preview in placeholder');
testsFailed++;
}

// Test 4: Small message should NOT be replaced
console.log('\n🧪 Test 4: Small message NOT replaced');
if (processedSmallMessage &&
processedSmallMessage.content === smallContent) {
console.log(' ✅ PASS - Small message unchanged');
testsPassed++;
} else {
console.error(' ❌ FAIL - Small message was modified');
console.error(' Expected:', smallContent);
console.error(' Got:', processedSmallMessage?.content);
testsFailed++;
}

// Test 5: Placeholder should inform Claude to search memory
console.log('\n🧪 Test 5: Placeholder instructs memory search');
if (processedLargeMessage?.content?.includes('long-term memory')) {
console.log(' ✅ PASS - Placeholder mentions long-term memory');
testsPassed++;
} else {
console.error(' ❌ FAIL - No memory search instruction');
testsFailed++;
}

// Summary
console.log('\n' + '═'.repeat(80));
console.log(`📊 Test Summary: ${testsPassed}/${testsPassed + testsFailed} passed`);
console.log('═'.repeat(80));

if (testsFailed === 0) {
console.log('✅ All tests passed! Large message replacement works correctly.');
console.log('\nBenefits validated:');
console.log(' - Prevents "prompt too long" errors on follow-up messages');
console.log(' - Large messages are automatically replaced with placeholders');
console.log(' - Small messages pass through unchanged');
console.log(' - Claude is informed to search its memory for details');
process.exit(0);
} else {
console.error(`\n❌ ${testsFailed} test(s) failed`);
process.exit(1);
}
}

testLargeMessageReplacement().catch(err => {
console.error('❌ Test error:', err);
process.exit(1);
});

Loading