diff --git a/plugin-nostr/test/selfReflection.analysis.test.js b/plugin-nostr/test/selfReflection.analysis.test.js new file mode 100644 index 0000000..414aa27 --- /dev/null +++ b/plugin-nostr/test/selfReflection.analysis.test.js @@ -0,0 +1,580 @@ +const { SelfReflectionEngine } = require('../lib/selfReflection'); + +describe('SelfReflectionEngine - Analysis and Edge Cases', () => { + let engine; + let mockRuntime; + let mockLogger; + + beforeEach(() => { + mockLogger = { + info: () => {}, + debug: () => {}, + warn: () => {} + }; + + mockRuntime = { + getSetting: (key) => { + const settings = { + NOSTR_SELF_REFLECTION_ENABLE: 'true' + }; + return settings[key] || null; + }, + agentId: 'test-agent-id', + getMemories: async () => [], + createMemory: async (memory) => ({ created: true, id: memory.id }), + getMemoryById: async (id) => null, + databaseAdapter: { + db: {} + } + }; + + engine = new SelfReflectionEngine(mockRuntime, mockLogger, { + createUniqueUuid: (runtime, seed) => `uuid-${seed}-${Date.now()}` + }); + }); + + describe('analyzeInteractionQuality', () => { + it('returns null when disabled', async () => { + engine.enabled = false; + const result = await engine.analyzeInteractionQuality(); + expect(result).toBe(null); + }); + + it('returns null when no interactions available', async () => { + mockRuntime.getMemories = async () => []; + const result = await engine.analyzeInteractionQuality(); + expect(result).toBe(null); + }); + + it('updates lastAnalysis on successful analysis', async () => { + const now = Date.now(); + + mockRuntime.getMemories = async () => [ + { + id: 'parent-1', + createdAt: now - 2000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { pubkey: 'user-1', content: 'Hello' }, + text: 'Hello' + } + }, + { + id: 'reply-1', + createdAt: now - 1000, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'Hi there!', + inReplyTo: 'parent-1' + } + } + ]; + + // Mock the generation function by replacing it temporarily + const originalGenerate = require('../lib/generation').generateWithModelOrFallback; + require('../lib/generation').generateWithModelOrFallback = async () => { + return JSON.stringify({ + strengths: ['friendly tone'], + weaknesses: ['could be more concise'], + recommendations: ['ask follow-up questions'], + patterns: ['emoji usage'], + exampleGoodReply: 'Hi there!', + exampleBadReply: null + }); + }; + + const result = await engine.analyzeInteractionQuality(); + + // Restore original function + require('../lib/generation').generateWithModelOrFallback = originalGenerate; + + expect(engine.lastAnalysis).toBeTruthy(); + expect(engine.lastAnalysis.timestamp).toBeTruthy(); + expect(engine.lastAnalysis.interactionsAnalyzed).toBeGreaterThan(0); + }); + }); + + describe('Error Handling', () => { + it('handles getMemories failure gracefully', async () => { + mockRuntime.getMemories = async () => { + throw new Error('Database error'); + }; + + const { interactions } = await engine.getRecentInteractions(); + + expect(interactions).toEqual([]); + }); + + it('handles createMemory failure in storeReflection', async () => { + mockRuntime.createMemory = async () => { + throw new Error('Storage error'); + }; + + const result = await engine.storeReflection({ + analysis: { strengths: ['test'] }, + interactions: [] + }); + + // Should not throw, just return false + expect(result).toBe(false); + }); + + it('handles getMemoryById failure gracefully', async () => { + const now = Date.now(); + + mockRuntime.getMemories = async () => [ + { + id: 'reply-1', + createdAt: now, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'Reply', + inReplyTo: 'missing-parent' + } + } + ]; + + mockRuntime.getMemoryById = async () => { + throw new Error('Memory not found'); + }; + + const { interactions } = await engine.getRecentInteractions(); + + // Should skip this interaction since parent is missing + expect(interactions).toEqual([]); + }); + + it('handles invalid memory structures', async () => { + mockRuntime.getMemories = async () => [ + null, + undefined, + {}, + { content: null }, + { id: 'valid', content: { source: 'nostr', text: 'text', inReplyTo: 'parent' } } + ]; + + const { interactions } = await engine.getRecentInteractions(); + + // Should filter out invalid memories + expect(interactions.length).toBe(0); // Still 0 because parent is missing + }); + + it('handles missing user profile manager gracefully', async () => { + engine.userProfileManager = null; + + const now = Date.now(); + mockRuntime.getMemories = async () => [ + { + id: 'parent-1', + createdAt: now - 2000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { pubkey: 'user-1', content: 'Hello' }, + text: 'Hello' + } + }, + { + id: 'reply-1', + createdAt: now - 1000, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'Hi!', + inReplyTo: 'parent-1' + } + } + ]; + + const { interactions } = await engine.getRecentInteractions(); + + expect(interactions.length).toBe(1); + expect(interactions[0].engagement).toBe('unknown'); + }); + + it('handles ensureSystemContext failure', async () => { + // Make ensureSystemContext throw + const originalEnsure = require('../lib/context').ensureNostrContextSystem; + require('../lib/context').ensureNostrContextSystem = async () => { + throw new Error('Context creation failed'); + }; + + const result = await engine.storeReflection({ + analysis: { strengths: ['test'] }, + interactions: [] + }); + + // Restore + require('../lib/context').ensureNostrContextSystem = originalEnsure; + + // Should handle gracefully + expect(typeof result).toBe('boolean'); + }); + }); + + describe('Edge Cases', () => { + it('handles interactions with missing text fields', async () => { + const now = Date.now(); + + mockRuntime.getMemories = async () => [ + { + id: 'parent-1', + createdAt: now - 2000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { pubkey: 'user-1' }, // No content field + text: '' + } + }, + { + id: 'reply-1', + createdAt: now - 1000, + roomId: 'room-1', + content: { + source: 'nostr', + text: '', // Empty text + inReplyTo: 'parent-1' + } + } + ]; + + const { interactions } = await engine.getRecentInteractions(); + + expect(interactions.length).toBe(0); + }); + + it('handles duplicate interactions', async () => { + const now = Date.now(); + const replyMemory = { + id: 'reply-1', + createdAt: now, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'Reply text', + inReplyTo: 'parent-1' + } + }; + + mockRuntime.getMemories = async () => [ + { + id: 'parent-1', + createdAt: now - 2000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { pubkey: 'user-1', content: 'Hello' }, + text: 'Hello' + } + }, + replyMemory, + replyMemory // Duplicate + ]; + + const { interactions } = await engine.getRecentInteractions(); + + expect(interactions.length).toBe(1); + }); + + it('handles very large conversation windows', async () => { + const now = Date.now(); + const memories = []; + + // Create 100 messages in same room + for (let i = 0; i < 100; i++) { + memories.push({ + id: `msg-${i}`, + createdAt: now - (100 - i) * 1000, + roomId: 'busy-room', + content: { + source: 'nostr', + text: `Message ${i}` + } + }); + } + + const replyMemory = memories[50]; + const parentMemory = memories[49]; + + const conversation = engine._buildConversationWindow(memories, replyMemory, parentMemory); + + // Should be limited by window size + expect(conversation.length).toBeLessThan(20); + }); + + it('handles reflections with missing analysis fields', () => { + const summary = engine._buildInsightsSummary({ + // Missing all standard fields + }); + + expect(summary).toBe(null); + }); + + it('handles extremely long text in interactions', () => { + const longText = 'a'.repeat(10000); + const interaction = { + userMessage: longText, + yourReply: longText, + conversation: [], + feedback: [], + signals: [] + }; + + const serialized = engine._serializeInteractionSnapshot(interaction); + + expect(serialized.userMessage.length).toBe(280); + expect(serialized.yourReply.length).toBe(280); + }); + + it('handles special characters in text', () => { + const specialText = '😀🎉✨ Special chars: <>&"\''; + const truncated = engine._truncate(specialText, 50); + + expect(truncated).toBeTruthy(); + expect(truncated.length).toBeLessThanOrEqual(50); + }); + + it('handles reflections without timestamp', async () => { + mockRuntime.getMemories = async () => [ + { + id: 'mem-1', + // No createdAt field + content: { + type: 'self_reflection', + data: { + // No generatedAt field + analysis: { + strengths: ['test'] + } + } + } + } + ]; + + const history = await engine.getReflectionHistory(); + + // Should still process the reflection + expect(history.length).toBeGreaterThanOrEqual(0); + }); + + it('handles circular references in analysis objects', () => { + const circular = { strengths: ['test'] }; + circular.self = circular; // Create circular reference + + // Should not throw when serializing + const serialized = engine._serializeInteractionSnapshot({ + userMessage: 'test', + yourReply: 'test', + metadata: circular + }); + + expect(serialized).toBeTruthy(); + }); + + it('handles concurrent calls to getLatestInsights', async () => { + mockRuntime.getMemories = async () => [ + { + id: 'mem-1', + createdAt: Date.now(), + content: { + type: 'self_reflection', + data: { + generatedAt: new Date().toISOString(), + analysis: { strengths: ['concurrent'] } + } + } + } + ]; + + // Make multiple concurrent calls + const results = await Promise.all([ + engine.getLatestInsights(), + engine.getLatestInsights(), + engine.getLatestInsights() + ]); + + // All should succeed + results.forEach(result => { + expect(result).toBeTruthy(); + }); + }); + + it('handles zero-length arrays in analysis', () => { + const summary = engine._buildInsightsSummary({ + strengths: [], + weaknesses: [], + recommendations: [], + patterns: [], + improvements: [], + regressions: [], + exampleGoodReply: null, + exampleBadReply: null + }); + + expect(summary).toBe(null); + }); + + it('handles undefined vs null values', () => { + const result1 = engine._truncate(undefined); + const result2 = engine._truncate(null); + + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('handles non-ASCII characters in pubkeys', () => { + const pubkey = 'npub1234567890абвгдежз'; + const masked = engine._maskPubkey(pubkey); + + expect(masked).toBeTruthy(); + expect(masked).toContain('…'); + }); + }); + + describe('Longitudinal Analysis Edge Cases', () => { + it('handles insufficient history', async () => { + mockRuntime.getMemories = async () => [ + { + id: 'mem-1', + createdAt: Date.now(), + content: { + type: 'self_reflection', + data: { + generatedAt: new Date().toISOString(), + analysis: { strengths: ['only one'] } + } + } + } + ]; + + const analysis = await engine.analyzeLongitudinalPatterns(); + + expect(analysis).toBe(null); + }); + + it('handles reflections with missing field arrays', () => { + const text1 = 'Some TEXT with CAPS!'; + const text2 = 'some text with caps!'; + + const norm1 = engine._normalizeForComparison(text1); + const norm2 = engine._normalizeForComparison(text2); + + expect(norm1).toBe(norm2); + }); + }); + + describe('Configuration Edge Cases', () => { + it('handles invalid temperature settings', () => { + const testRuntime = { + getSetting: (key) => { + if (key === 'NOSTR_SELF_REFLECTION_TEMPERATURE') return 'invalid'; + return null; + } + }; + + const testEngine = new SelfReflectionEngine(testRuntime, mockLogger, {}); + + // Should use default temperature + expect(testEngine.temperature).toBe(0.6); + }); + + it('handles invalid maxTokens settings', () => { + const testRuntime = { + getSetting: (key) => { + if (key === 'NOSTR_SELF_REFLECTION_MAX_TOKENS') return -100; + return null; + } + }; + + const testEngine = new SelfReflectionEngine(testRuntime, mockLogger, {}); + + // Should use default maxTokens + expect(testEngine.maxTokens).toBe(800); + }); + + it('handles invalid interaction limit', () => { + const testRuntime = { + getSetting: (key) => { + if (key === 'NOSTR_SELF_REFLECTION_INTERACTION_LIMIT') return 'abc'; + return null; + } + }; + + const testEngine = new SelfReflectionEngine(testRuntime, mockLogger, {}); + + // Should use default limit + expect(testEngine.maxInteractions).toBe(40); + }); + + it('handles mixed case enable setting', () => { + const testRuntime = { + getSetting: (key) => { + if (key === 'NOSTR_SELF_REFLECTION_ENABLE') return 'TrUe'; + return null; + } + }; + + const testEngine = new SelfReflectionEngine(testRuntime, mockLogger, {}); + + expect(testEngine.enabled).toBe(true); + }); + + it('handles "false" string for enable setting', () => { + const testRuntime = { + getSetting: (key) => { + if (key === 'NOSTR_SELF_REFLECTION_ENABLE') return 'false'; + return null; + } + }; + + const testEngine = new SelfReflectionEngine(testRuntime, mockLogger, {}); + + expect(testEngine.enabled).toBe(false); + }); + }); + + describe('Memory Consistency', () => { + it('maintains consistent memory IDs', () => { + const uuid1 = engine._createUuid('same-seed'); + // Wait a tiny bit to ensure timestamp changes + const uuid2 = engine._createUuid('same-seed'); + + // Should be different due to timestamp + expect(uuid1).not.toBe(uuid2); + }); + + it('handles missing roomId in memories', async () => { + mockRuntime.getMemories = async () => [ + { + id: 'parent-1', + createdAt: Date.now() - 2000, + // No roomId + content: { + source: 'nostr', + event: { pubkey: 'user-1', content: 'Hello' }, + text: 'Hello' + } + }, + { + id: 'reply-1', + createdAt: Date.now() - 1000, + // No roomId + content: { + source: 'nostr', + text: 'Hi!', + inReplyTo: 'parent-1' + } + } + ]; + + const { interactions } = await engine.getRecentInteractions(); + + // Should still process interaction + expect(interactions.length).toBe(1); + }); + }); +}); diff --git a/plugin-nostr/test/selfReflection.core.test.js b/plugin-nostr/test/selfReflection.core.test.js new file mode 100644 index 0000000..e830a9f --- /dev/null +++ b/plugin-nostr/test/selfReflection.core.test.js @@ -0,0 +1,643 @@ +const { SelfReflectionEngine } = require('../lib/selfReflection'); + +describe('SelfReflectionEngine - Core Functionality', () => { + let engine; + let mockRuntime; + let mockLogger; + + beforeEach(() => { + mockLogger = { + info: () => {}, + debug: () => {}, + warn: () => {} + }; + + mockRuntime = { + getSetting: (key) => { + const settings = { + NOSTR_SELF_REFLECTION_ENABLE: 'true', + NOSTR_SELF_REFLECTION_INTERACTION_LIMIT: '40', + NOSTR_SELF_REFLECTION_TEMPERATURE: '0.6', + NOSTR_SELF_REFLECTION_MAX_TOKENS: '800' + }; + return settings[key] || null; + }, + agentId: 'test-agent-id', + getMemories: async () => [], + createMemory: async (memory) => ({ created: true, id: memory.id }), + getMemoryById: async (id) => null + }; + + engine = new SelfReflectionEngine(mockRuntime, mockLogger, { + createUniqueUuid: (runtime, seed) => `uuid-${seed}-${Date.now()}` + }); + }); + + describe('Initialization', () => { + it('initializes with default configuration', () => { + expect(engine.enabled).toBe(true); + expect(engine.maxInteractions).toBe(40); + expect(engine.temperature).toBe(0.6); + expect(engine.maxTokens).toBe(800); + }); + + it('handles disabled state', () => { + const disabledRuntime = { + getSetting: (key) => key === 'NOSTR_SELF_REFLECTION_ENABLE' ? 'false' : null + }; + const disabledEngine = new SelfReflectionEngine(disabledRuntime, mockLogger, {}); + expect(disabledEngine.enabled).toBe(false); + }); + + it('accepts custom maxInteractions via options', () => { + const customEngine = new SelfReflectionEngine(mockRuntime, mockLogger, { + maxInteractions: 25 + }); + expect(customEngine.maxInteractions).toBe(25); + }); + + it('accepts custom temperature via options', () => { + const customEngine = new SelfReflectionEngine(mockRuntime, mockLogger, { + temperature: 0.8 + }); + expect(customEngine.temperature).toBe(0.8); + }); + + it('accepts custom maxTokens via options', () => { + const customEngine = new SelfReflectionEngine(mockRuntime, mockLogger, { + maxTokens: 1000 + }); + expect(customEngine.maxTokens).toBe(1000); + }); + + it('initializes with null system context', () => { + expect(engine._systemContext).toBe(null); + expect(engine._systemContextPromise).toBe(null); + }); + + it('initializes with null lastAnalysis', () => { + expect(engine.lastAnalysis).toBe(null); + }); + }); + + describe('_extractJson', () => { + it('extracts JSON from string response', () => { + const response = 'Some text before {"key": "value", "number": 42} some text after'; + const result = engine._extractJson(response); + expect(result).toEqual({ key: 'value', number: 42 }); + }); + + it('extracts complex JSON with nested objects', () => { + const response = '{"strengths": ["one", "two"], "weaknesses": [], "nested": {"key": "val"}}'; + const result = engine._extractJson(response); + expect(result).toEqual({ + strengths: ['one', 'two'], + weaknesses: [], + nested: { key: 'val' } + }); + }); + + it('returns null for invalid JSON', () => { + const response = 'No JSON here at all'; + const result = engine._extractJson(response); + expect(result).toBe(null); + }); + + it('returns null for malformed JSON', () => { + const response = '{"incomplete": '; + const result = engine._extractJson(response); + expect(result).toBe(null); + }); + + it('returns null for null input', () => { + const result = engine._extractJson(null); + expect(result).toBe(null); + }); + + it('returns null for non-string input', () => { + const result = engine._extractJson(123); + expect(result).toBe(null); + }); + }); + + describe('_truncate', () => { + it('truncates text longer than limit', () => { + const text = 'a'.repeat(400); + const result = engine._truncate(text, 320); + expect(result.length).toBe(320); + expect(result.endsWith('…')).toBe(true); + }); + + it('returns text unchanged if within limit', () => { + const text = 'Short text'; + const result = engine._truncate(text, 320); + expect(result).toBe('Short text'); + }); + + it('trims and normalizes whitespace', () => { + const text = ' Multiple spaces here '; + const result = engine._truncate(text, 320); + expect(result).toBe('Multiple spaces here'); + }); + + it('returns empty string for null input', () => { + const result = engine._truncate(null); + expect(result).toBe(''); + }); + + it('uses default limit of 320', () => { + const text = 'a'.repeat(400); + const result = engine._truncate(text); + expect(result.length).toBe(320); + }); + }); + + describe('_trim', () => { + it('trims text to specified limit', () => { + const text = 'a'.repeat(5000); + const result = engine._trim(text, 4000); + expect(result.length).toBe(4001); // 4000 + ellipsis + expect(result.endsWith('…')).toBe(true); + }); + + it('returns text unchanged if within limit', () => { + const text = 'Short text'; + const result = engine._trim(text, 1000); + expect(result).toBe('Short text'); + }); + + it('returns text unchanged if no limit provided', () => { + const text = 'Any length text'; + const result = engine._trim(text); + expect(result).toBe('Any length text'); + }); + + it('handles non-string input', () => { + const result = engine._trim(123, 100); + expect(result).toBe(123); + }); + + it('returns null for null input', () => { + const result = engine._trim(null, 100); + expect(result).toBe(null); + }); + }); + + describe('_maskPubkey', () => { + it('masks a pubkey showing only first 6 and last 4 characters', () => { + const pubkey = 'npub1234567890abcdefghijklmnopqrstuvwxyz'; + const result = engine._maskPubkey(pubkey); + expect(result).toBe('npub12…wxyz'); + }); + + it('returns "unknown" for null input', () => { + const result = engine._maskPubkey(null); + expect(result).toBe('unknown'); + }); + + it('returns "unknown" for non-string input', () => { + const result = engine._maskPubkey(123); + expect(result).toBe('unknown'); + }); + + it('handles short pubkeys', () => { + const pubkey = 'short'; + const result = engine._maskPubkey(pubkey); + expect(result).toBe('short…hort'); + }); + }); + + describe('_formatEngagement', () => { + it('formats complete engagement stats', () => { + const stats = { + averageEngagement: 0.72, + successRate: 0.80, + totalInteractions: 15, + dominantSentiment: 'positive' + }; + const result = engine._formatEngagement(stats); + expect(result).toContain('avg=0.72'); + expect(result).toContain('success=80%'); + expect(result).toContain('total=15'); + expect(result).toContain('sentiment=positive'); + }); + + it('handles partial stats', () => { + const stats = { + averageEngagement: 0.5, + totalInteractions: 10 + }; + const result = engine._formatEngagement(stats); + expect(result).toContain('avg=0.50'); + expect(result).toContain('total=10'); + expect(result).not.toContain('success'); + expect(result).not.toContain('sentiment'); + }); + + it('returns "unknown" for null stats', () => { + const result = engine._formatEngagement(null); + expect(result).toBe('unknown'); + }); + + it('returns "unknown" for empty stats', () => { + const result = engine._formatEngagement({}); + expect(result).toBe('unknown'); + }); + + it('handles NaN values gracefully', () => { + const stats = { + averageEngagement: NaN, + successRate: 0.5 + }; + const result = engine._formatEngagement(stats); + expect(result).toContain('success=50%'); + expect(result).not.toContain('avg'); + }); + }); + + describe('_toIsoString', () => { + it('converts timestamp to ISO string', () => { + const timestamp = Date.parse('2025-10-05T10:00:00.000Z'); + const result = engine._toIsoString(timestamp); + expect(result).toBe('2025-10-05T10:00:00.000Z'); + }); + + it('returns null for non-finite timestamp', () => { + const result = engine._toIsoString(NaN); + expect(result).toBe(null); + }); + + it('returns null for null input', () => { + const result = engine._toIsoString(null); + expect(result).toBe(null); + }); + + it('returns null for undefined input', () => { + const result = engine._toIsoString(undefined); + expect(result).toBe(null); + }); + + it('handles current timestamp', () => { + const now = Date.now(); + const result = engine._toIsoString(now); + expect(result).toBeTruthy(); + expect(result).toContain('T'); + expect(result).toContain('Z'); + }); + }); + + describe('_toLimitedList', () => { + it('limits array to specified size', () => { + const arr = ['item1', 'item2', 'item3', 'item4', 'item5', 'item6']; + const result = engine._toLimitedList(arr, 4); + expect(result.length).toBe(4); + expect(result).toEqual(['item1', 'item2', 'item3', 'item4']); + }); + + it('truncates long strings in array', () => { + const arr = ['a'.repeat(300)]; + const result = engine._toLimitedList(arr, 4); + expect(result[0].length).toBe(220); + expect(result[0].endsWith('…')).toBe(true); + }); + + it('filters out falsy values', () => { + const arr = ['item1', null, '', 'item2', undefined, 'item3']; + const result = engine._toLimitedList(arr, 10); + expect(result).toEqual(['item1', 'item2', 'item3']); + }); + + it('returns empty array for non-array input', () => { + const result = engine._toLimitedList('not an array', 4); + expect(result).toEqual([]); + }); + + it('uses default limit of 4', () => { + const arr = ['1', '2', '3', '4', '5', '6']; + const result = engine._toLimitedList(arr); + expect(result.length).toBe(4); + }); + }); + + describe('_normalizeForComparison', () => { + it('normalizes text to lowercase', () => { + const result = engine._normalizeForComparison('Hello World'); + expect(result).toBe('hello world'); + }); + + it('removes punctuation', () => { + const result = engine._normalizeForComparison('Hello, World! How are you?'); + expect(result).toBe('hello world how are you'); + }); + + it('normalizes multiple spaces', () => { + const result = engine._normalizeForComparison('too many spaces'); + expect(result).toBe('too many spaces'); + }); + + it('trims whitespace', () => { + const result = engine._normalizeForComparison(' text with spaces '); + expect(result).toBe('text with spaces'); + }); + + it('returns empty string for null input', () => { + const result = engine._normalizeForComparison(null); + expect(result).toBe(''); + }); + + it('returns empty string for non-string input', () => { + const result = engine._normalizeForComparison(123); + expect(result).toBe(''); + }); + }); + + describe('_createUuid', () => { + it('uses injected createUniqueUuid function', () => { + const uuid = engine._createUuid('test-seed'); + expect(uuid).toContain('uuid-test-seed-'); + }); + + it('falls back to default UUID generation', () => { + const engineWithoutUuid = new SelfReflectionEngine( + { ...mockRuntime, createUniqueUuid: null }, + mockLogger, + {} + ); + const uuid = engineWithoutUuid._createUuid('fallback-seed'); + expect(uuid).toContain('fallback-seed:'); + expect(uuid).toContain(':'); + }); + + it('generates unique UUIDs for different seeds', () => { + const uuid1 = engine._createUuid('seed1'); + const uuid2 = engine._createUuid('seed2'); + expect(uuid1).not.toBe(uuid2); + }); + }); + + describe('_getLargeModelType', () => { + it('returns a valid model type', () => { + const modelType = engine._getLargeModelType(); + expect(modelType).toBeTruthy(); + expect(typeof modelType).toBe('string'); + }); + }); + + describe('_buildInsightsSummary', () => { + it('builds summary from complete analysis', () => { + const analysis = { + strengths: ['strength1', 'strength2'], + weaknesses: ['weakness1'], + recommendations: ['rec1', 'rec2'], + patterns: ['pattern1'], + improvements: ['improvement1'], + regressions: ['regression1'], + exampleGoodReply: 'This is a great reply!', + exampleBadReply: 'Bad reply' + }; + const meta = { + generatedAt: Date.now(), + generatedAtIso: '2025-10-05T10:00:00.000Z', + interactionsAnalyzed: 10 + }; + + const summary = engine._buildInsightsSummary(analysis, meta); + + expect(summary).toBeTruthy(); + expect(summary.strengths).toEqual(['strength1', 'strength2']); + expect(summary.weaknesses).toEqual(['weakness1']); + expect(summary.recommendations).toEqual(['rec1', 'rec2']); + expect(summary.patterns).toEqual(['pattern1']); + expect(summary.improvements).toEqual(['improvement1']); + expect(summary.regressions).toEqual(['regression1']); + expect(summary.exampleGoodReply).toBe('This is a great reply!'); + expect(summary.exampleBadReply).toBe('Bad reply'); + expect(summary.interactionsAnalyzed).toBe(10); + expect(summary.generatedAtIso).toBe('2025-10-05T10:00:00.000Z'); + }); + + it('returns null for empty analysis', () => { + const analysis = { + strengths: [], + weaknesses: [], + recommendations: [], + patterns: [] + }; + const summary = engine._buildInsightsSummary(analysis); + expect(summary).toBe(null); + }); + + it('returns null for null analysis', () => { + const summary = engine._buildInsightsSummary(null); + expect(summary).toBe(null); + }); + + it('returns null for non-object analysis', () => { + const summary = engine._buildInsightsSummary('not an object'); + expect(summary).toBe(null); + }); + + it('limits lists to specified size', () => { + const analysis = { + strengths: ['1', '2', '3', '4', '5', '6', '7', '8'] + }; + const summary = engine._buildInsightsSummary(analysis, { limit: 3 }); + expect(summary.strengths.length).toBe(3); + }); + + it('generates ISO string from timestamp if not provided', () => { + const timestamp = Date.parse('2025-10-05T12:00:00.000Z'); + const analysis = { strengths: ['one'] }; + const summary = engine._buildInsightsSummary(analysis, { generatedAt: timestamp }); + expect(summary.generatedAtIso).toBe('2025-10-05T12:00:00.000Z'); + }); + }); + + describe('_isAgentReplyMemory', () => { + it('identifies agent reply memory correctly', () => { + const memory = { + content: { + source: 'nostr', + text: 'This is a reply', + inReplyTo: 'parent-id' + } + }; + expect(engine._isAgentReplyMemory(memory)).toBe(true); + }); + + it('rejects memory without inReplyTo', () => { + const memory = { + content: { + source: 'nostr', + text: 'This is not a reply' + } + }; + expect(engine._isAgentReplyMemory(memory)).toBe(false); + }); + + it('rejects memory without text', () => { + const memory = { + content: { + source: 'nostr', + inReplyTo: 'parent-id' + } + }; + expect(engine._isAgentReplyMemory(memory)).toBe(false); + }); + + it('rejects memory from non-nostr source', () => { + const memory = { + content: { + source: 'twitter', + text: 'Reply text', + inReplyTo: 'parent-id' + } + }; + expect(engine._isAgentReplyMemory(memory)).toBe(false); + }); + + it('rejects memory without content', () => { + const memory = {}; + expect(engine._isAgentReplyMemory(memory)).toBe(false); + }); + + it('rejects null memory', () => { + expect(engine._isAgentReplyMemory(null)).toBe(false); + }); + }); + + describe('_inferRoleFromMemory', () => { + const replyMemory = { id: 'reply-1', content: { text: 'my reply' } }; + + it('identifies reply memory as "you"', () => { + const result = engine._inferRoleFromMemory(replyMemory, replyMemory); + expect(result).toBe('you'); + }); + + it('identifies memory with agent pubkey as "you"', () => { + engine.agentPubkey = 'agent-pubkey-123'; + const memory = { + id: 'mem-1', + content: { + event: { pubkey: 'agent-pubkey-123' } + } + }; + const result = engine._inferRoleFromMemory(memory, replyMemory); + expect(result).toBe('you'); + }); + + it('identifies memory with different pubkey as "user"', () => { + const memory = { + id: 'mem-1', + content: { + event: { pubkey: 'user-pubkey-456' } + } + }; + const result = engine._inferRoleFromMemory(memory, replyMemory); + expect(result).toBe('user'); + }); + + it('identifies nostr memory without event as "you"', () => { + const memory = { + id: 'mem-1', + content: { + source: 'nostr', + text: 'some text' + } + }; + const result = engine._inferRoleFromMemory(memory, replyMemory); + expect(result).toBe('you'); + }); + + it('identifies system memory with triggerEvent as "system"', () => { + const memory = { + id: 'mem-1', + content: { + source: 'nostr', + data: { triggerEvent: 'some-event' } + } + }; + const result = engine._inferRoleFromMemory(memory, replyMemory); + expect(result).toBe('system'); + }); + + it('returns "unknown" for unidentifiable memory', () => { + const memory = { + id: 'mem-1', + content: {} + }; + const result = engine._inferRoleFromMemory(memory, replyMemory); + expect(result).toBe('unknown'); + }); + + it('returns "unknown" for null memory', () => { + const result = engine._inferRoleFromMemory(null, replyMemory); + expect(result).toBe('unknown'); + }); + }); + + describe('_serializeInteractionSnapshot', () => { + it('serializes complete interaction', () => { + const interaction = { + userMessage: 'User message here', + yourReply: 'Agent reply here', + engagement: 'avg=0.8', + metadata: { pubkey: 'npub123', createdAtIso: '2025-10-05T10:00:00.000Z' }, + conversation: [ + { role: 'user', author: 'user1', text: 'Hello', createdAtIso: '2025-10-05T10:00:00.000Z', type: 'mention' } + ], + feedback: [ + { author: 'user2', summary: 'Great response!', createdAtIso: '2025-10-05T10:01:00.000Z' } + ], + signals: ['signal1: info', 'signal2: more info'] + }; + + const result = engine._serializeInteractionSnapshot(interaction); + + expect(result).toBeTruthy(); + expect(result.userMessage).toBe('User message here'); + expect(result.yourReply).toBe('Agent reply here'); + expect(result.engagement).toBe('avg=0.8'); + expect(result.conversation.length).toBe(1); + expect(result.feedback.length).toBe(1); + expect(result.signals.length).toBe(2); + }); + + it('handles missing optional fields', () => { + const interaction = { + userMessage: 'Message', + yourReply: 'Reply' + }; + + const result = engine._serializeInteractionSnapshot(interaction); + + expect(result).toBeTruthy(); + expect(result.engagement).toBe(null); + expect(result.conversation).toEqual([]); + expect(result.feedback).toEqual([]); + expect(result.signals).toEqual([]); + }); + + it('truncates long text fields', () => { + const interaction = { + userMessage: 'a'.repeat(500), + yourReply: 'b'.repeat(500) + }; + + const result = engine._serializeInteractionSnapshot(interaction); + + expect(result.userMessage.length).toBe(280); + expect(result.yourReply.length).toBe(280); + }); + + it('returns null for null interaction', () => { + const result = engine._serializeInteractionSnapshot(null); + expect(result).toBe(null); + }); + + it('returns null for non-object interaction', () => { + const result = engine._serializeInteractionSnapshot('not an object'); + expect(result).toBe(null); + }); + }); +}); diff --git a/plugin-nostr/test/selfReflection.integration.test.js b/plugin-nostr/test/selfReflection.integration.test.js new file mode 100644 index 0000000..3becd76 --- /dev/null +++ b/plugin-nostr/test/selfReflection.integration.test.js @@ -0,0 +1,832 @@ +const { SelfReflectionEngine } = require('../lib/selfReflection'); + +describe('SelfReflectionEngine - Integration Tests', () => { + let engine; + let mockRuntime; + let mockLogger; + let mockMemories; + + beforeEach(() => { + mockMemories = []; + + mockLogger = { + info: () => {}, + debug: () => {}, + warn: () => {} + }; + + mockRuntime = { + getSetting: (key) => { + const settings = { + NOSTR_SELF_REFLECTION_ENABLE: 'true', + NOSTR_PUBLIC_KEY: 'agent-pubkey-123' + }; + return settings[key] || null; + }, + agentId: 'test-agent-id', + getMemories: async ({ tableName, roomId, count, agentId }) => { + return mockMemories.slice(0, count); + }, + createMemory: async (memory) => ({ created: true, id: memory.id }), + getMemoryById: async (id) => { + return mockMemories.find(m => m.id === id) || null; + } + }; + + engine = new SelfReflectionEngine(mockRuntime, mockLogger, { + createUniqueUuid: (runtime, seed) => `uuid-${seed}-${Date.now()}` + }); + }); + + describe('storeReflection', () => { + it('stores reflection with all payload data', async () => { + const payload = { + analysis: { + strengths: ['clear communication'], + weaknesses: ['verbose'], + recommendations: ['be concise'] + }, + raw: 'Raw LLM output', + prompt: 'The prompt used', + interactions: [ + { + userMessage: 'Hello', + yourReply: 'Hi there', + engagement: 'avg=0.5', + conversation: [], + feedback: [], + signals: [] + } + ], + contextSignals: ['signal1'], + previousReflections: [], + longitudinalAnalysis: null + }; + + const result = await engine.storeReflection(payload); + expect(result).toBe(true); + }); + + it('handles storage when runtime lacks createMemory', async () => { + const noMemoryRuntime = { ...mockRuntime }; + delete noMemoryRuntime.createMemory; + + const testEngine = new SelfReflectionEngine(noMemoryRuntime, mockLogger, {}); + const result = await testEngine.storeReflection({ analysis: {} }); + + expect(result).toBe(false); + }); + + it('updates insights cache after storing', async () => { + const payload = { + analysis: { + strengths: ['good work'], + weaknesses: [] + }, + interactions: [] + }; + + await engine.storeReflection(payload); + + expect(engine._latestInsightsCache).toBeTruthy(); + expect(engine._latestInsightsCache.data).toBeTruthy(); + }); + }); + + describe('getReflectionHistory', () => { + it('retrieves reflection history', async () => { + const now = Date.now(); + mockMemories = [ + { + id: 'mem-1', + createdAt: now - 1000, + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now - 1000).toISOString(), + analysis: { + strengths: ['clarity'], + weaknesses: [] + } + } + } + } + ]; + + const history = await engine.getReflectionHistory({ limit: 5 }); + + expect(history.length).toBe(1); + expect(history[0].strengths).toContain('clarity'); + }); + + it('respects maxAgeHours parameter', async () => { + const now = Date.now(); + const oneDay = 24 * 60 * 60 * 1000; + + mockMemories = [ + { + id: 'mem-recent', + createdAt: now - 1000, + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now - 1000).toISOString(), + analysis: { strengths: ['recent'] } + } + } + }, + { + id: 'mem-old', + createdAt: now - (2 * oneDay), + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now - (2 * oneDay)).toISOString(), + analysis: { strengths: ['old'] } + } + } + } + ]; + + const history = await engine.getReflectionHistory({ limit: 10, maxAgeHours: 12 }); + + expect(history.length).toBe(1); + expect(history[0].strengths).toContain('recent'); + }); + + it('returns empty array when disabled', async () => { + engine.enabled = false; + const history = await engine.getReflectionHistory(); + expect(history).toEqual([]); + }); + + it('returns empty array when no runtime', async () => { + engine.runtime = null; + const history = await engine.getReflectionHistory(); + expect(history).toEqual([]); + }); + + it('limits results to requested count', async () => { + mockMemories = Array.from({ length: 20 }, (_, i) => ({ + id: `mem-${i}`, + createdAt: Date.now() - i * 1000, + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(Date.now() - i * 1000).toISOString(), + analysis: { strengths: [`strength-${i}`] } + } + } + })); + + const history = await engine.getReflectionHistory({ limit: 3 }); + + expect(history.length).toBe(3); + }); + }); + + describe('getLatestInsights', () => { + it('retrieves latest insights from memory', async () => { + const now = Date.now(); + mockMemories = [ + { + id: 'mem-1', + createdAt: now, + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now).toISOString(), + analysis: { + strengths: ['excellent'], + weaknesses: ['needs work'] + }, + interactionsAnalyzed: 5 + } + } + } + ]; + + const insights = await engine.getLatestInsights(); + + expect(insights).toBeTruthy(); + expect(insights.strengths).toContain('excellent'); + expect(insights.weaknesses).toContain('needs work'); + expect(insights.interactionsAnalyzed).toBe(5); + }); + + it('uses cache when available and fresh', async () => { + engine._latestInsightsCache = { + timestamp: Date.now(), + data: { strengths: ['cached'] } + }; + + const insights = await engine.getLatestInsights({ cacheMs: 60000 }); + + expect(insights.strengths).toContain('cached'); + }); + + it('refreshes cache when expired', async () => { + const now = Date.now(); + engine._latestInsightsCache = { + timestamp: now - 100000, // Old cache + data: { strengths: ['old'] } + }; + + mockMemories = [ + { + id: 'mem-new', + createdAt: now, + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now).toISOString(), + analysis: { strengths: ['fresh'] } + } + } + } + ]; + + const insights = await engine.getLatestInsights({ cacheMs: 5000 }); + + expect(insights.strengths).toContain('fresh'); + }); + + it('falls back to lastAnalysis when no memories', async () => { + mockMemories = []; + engine.lastAnalysis = { + timestamp: Date.now(), + interactionsAnalyzed: 3, + strengths: ['fallback-strength'], + weaknesses: ['fallback-weakness'] + }; + + const insights = await engine.getLatestInsights(); + + expect(insights).toBeTruthy(); + expect(insights.strengths).toContain('fallback-strength'); + }); + + it('returns null when disabled', async () => { + engine.enabled = false; + const insights = await engine.getLatestInsights(); + expect(insights).toBe(null); + }); + + it('respects maxAgeHours parameter', async () => { + const now = Date.now(); + const oneDay = 24 * 60 * 60 * 1000; + + mockMemories = [ + { + id: 'mem-old', + createdAt: now - (2 * oneDay), + content: { + type: 'self_reflection', + data: { + generatedAt: new Date(now - (2 * oneDay)).toISOString(), + analysis: { strengths: ['too old'] } + } + } + } + ]; + + const insights = await engine.getLatestInsights({ maxAgeHours: 12 }); + + expect(insights).toBe(null); + }); + }); + + describe('getRecentInteractions', () => { + it('retrieves and processes agent reply memories', async () => { + const now = Date.now(); + + mockMemories = [ + { + id: 'parent-1', + createdAt: now - 2000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { + pubkey: 'user-pubkey-1', + content: 'Hello Pixel!' + }, + text: 'Hello Pixel!' + } + }, + { + id: 'reply-1', + createdAt: now - 1000, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'Hi! How can I help?', + inReplyTo: 'parent-1' + } + } + ]; + + const { interactions, contextSignals } = await engine.getRecentInteractions(5); + + expect(interactions.length).toBe(1); + expect(interactions[0].userMessage).toContain('Hello Pixel'); + expect(interactions[0].yourReply).toContain('Hi! How can I help'); + }); + + it('returns empty when no runtime', async () => { + engine.runtime = null; + const result = await engine.getRecentInteractions(); + + expect(result.interactions).toEqual([]); + expect(result.contextSignals).toEqual([]); + }); + + it('returns empty when runtime lacks getMemories', async () => { + const noMemoriesRuntime = { agentId: 'test-id' }; + const testEngine = new SelfReflectionEngine(noMemoriesRuntime, mockLogger, {}); + + const result = await testEngine.getRecentInteractions(); + + expect(result.interactions).toEqual([]); + expect(result.contextSignals).toEqual([]); + }); + + it('fetches parent memory when not in cache', async () => { + const now = Date.now(); + + mockMemories = [ + { + id: 'reply-1', + createdAt: now, + roomId: 'room-1', + content: { + source: 'nostr', + text: 'My reply', + inReplyTo: 'parent-external' + } + }, + { + id: 'parent-external', + createdAt: now - 1000, + roomId: 'room-1', + content: { + source: 'nostr', + event: { + pubkey: 'user-1', + content: 'External parent message' + }, + text: 'External parent message' + } + } + ]; + + const { interactions } = await engine.getRecentInteractions(5); + + expect(interactions.length).toBe(1); + expect(interactions[0].userMessage).toContain('External parent'); + }); + + it('limits interactions to requested count', async () => { + const now = Date.now(); + + mockMemories = []; + for (let i = 0; i < 20; i++) { + mockMemories.push({ + id: `parent-${i}`, + createdAt: now - (i * 1000) - 500, + roomId: `room-${i}`, + content: { + source: 'nostr', + event: { pubkey: 'user', content: `Message ${i}` }, + text: `Message ${i}` + } + }); + mockMemories.push({ + id: `reply-${i}`, + createdAt: now - (i * 1000), + roomId: `room-${i}`, + content: { + source: 'nostr', + text: `Reply ${i}`, + inReplyTo: `parent-${i}` + } + }); + } + + const { interactions } = await engine.getRecentInteractions(5); + + expect(interactions.length).toBeLessThanOrEqual(5); + }); + }); + + describe('_buildConversationWindow', () => { + it('builds conversation window with before/after messages', () => { + const now = Date.now(); + const roomMemories = [ + { id: 'msg-1', createdAt: now - 5000, content: { text: 'Message 1' } }, + { id: 'msg-2', createdAt: now - 4000, content: { text: 'Message 2' } }, + { id: 'parent', createdAt: now - 3000, content: { text: 'Parent message' } }, + { id: 'reply', createdAt: now - 2000, content: { text: 'Reply message' } }, + { id: 'msg-3', createdAt: now - 1000, content: { text: 'Message 3' } }, + { id: 'msg-4', createdAt: now, content: { text: 'Message 4' } } + ]; + + const replyMemory = roomMemories.find(m => m.id === 'reply'); + const parentMemory = roomMemories.find(m => m.id === 'parent'); + + const conversation = engine._buildConversationWindow(roomMemories, replyMemory, parentMemory); + + expect(conversation.length).toBeGreaterThan(0); + expect(conversation.some(entry => entry.text.includes('Parent message'))).toBe(true); + expect(conversation.some(entry => entry.text.includes('Reply message'))).toBe(true); + }); + + it('returns empty array for empty roomMemories', () => { + const replyMemory = { id: 'reply', content: { text: 'Reply' } }; + const parentMemory = { id: 'parent', content: { text: 'Parent' } }; + + const conversation = engine._buildConversationWindow([], replyMemory, parentMemory); + + expect(conversation).toEqual([]); + }); + + it('includes parent memory if not in slice', () => { + const now = Date.now(); + const roomMemories = [ + { id: 'reply', createdAt: now, content: { text: 'Reply' } } + ]; + const replyMemory = roomMemories[0]; + const parentMemory = { id: 'external-parent', createdAt: now - 10000, content: { text: 'External parent' } }; + + const conversation = engine._buildConversationWindow(roomMemories, replyMemory, parentMemory); + + expect(conversation.some(entry => entry.text.includes('External parent'))).toBe(true); + }); + }); + + describe('_formatConversationEntry', () => { + const replyMemory = { id: 'reply-1', content: { text: 'My reply' } }; + + it('formats conversation entry with all fields', () => { + const memory = { + id: 'msg-1', + createdAt: Date.parse('2025-10-05T10:00:00.000Z'), + content: { + type: 'nostr_mention', + text: 'Hello there!', + event: { pubkey: 'user-pubkey-123' } + } + }; + + const entry = engine._formatConversationEntry(memory, replyMemory); + + expect(entry.id).toBe('msg-1'); + expect(entry.text).toBe('Hello there!'); + expect(entry.type).toBe('nostr_mention'); + expect(entry.createdAtIso).toBe('2025-10-05T10:00:00.000Z'); + expect(entry.role).toBe('user'); + }); + + it('extracts text from event content', () => { + const memory = { + id: 'msg-1', + content: { + event: { + pubkey: 'user-123', + content: 'Text from event' + } + } + }; + + const entry = engine._formatConversationEntry(memory, replyMemory); + + expect(entry.text).toBe('Text from event'); + }); + + it('truncates long text', () => { + const memory = { + id: 'msg-1', + content: { + text: 'a'.repeat(500) + } + }; + + const entry = engine._formatConversationEntry(memory, replyMemory); + + expect(entry.text.length).toBe(320); + expect(entry.text.endsWith('…')).toBe(true); + }); + }); + + describe('_collectFeedback', () => { + it('collects feedback messages after reply', () => { + const conversationEntries = [ + { id: 'parent', role: 'user', text: 'Question', author: 'user1' }, + { id: 'reply', role: 'you', text: 'Answer', author: 'you', isReply: true }, + { id: 'feedback1', role: 'user', text: 'Thanks!', author: 'user1', createdAtIso: '2025-10-05T10:00:00.000Z' }, + { id: 'feedback2', role: 'user', text: 'Very helpful', author: 'user2', createdAtIso: '2025-10-05T10:01:00.000Z' } + ]; + + const feedback = engine._collectFeedback(conversationEntries, 'reply'); + + expect(feedback.length).toBe(2); + expect(feedback[0].summary).toBe('Thanks!'); + expect(feedback[1].summary).toBe('Very helpful'); + }); + + it('limits feedback to 3 items', () => { + const conversationEntries = [ + { id: 'reply', role: 'you', isReply: true }, + { id: 'f1', role: 'user', text: 'Feedback 1', author: 'u1' }, + { id: 'f2', role: 'user', text: 'Feedback 2', author: 'u2' }, + { id: 'f3', role: 'user', text: 'Feedback 3', author: 'u3' }, + { id: 'f4', role: 'user', text: 'Feedback 4', author: 'u4' }, + { id: 'f5', role: 'user', text: 'Feedback 5', author: 'u5' } + ]; + + const feedback = engine._collectFeedback(conversationEntries, 'reply'); + + expect(feedback.length).toBe(3); + }); + + it('filters out agent messages', () => { + const conversationEntries = [ + { id: 'reply', role: 'you', isReply: true }, + { id: 'f1', role: 'user', text: 'User feedback', author: 'user1' }, + { id: 'f2', role: 'you', text: 'Agent message', author: 'you' } + ]; + + const feedback = engine._collectFeedback(conversationEntries, 'reply'); + + expect(feedback.length).toBe(1); + expect(feedback[0].summary).toBe('User feedback'); + }); + + it('returns empty array for empty conversation', () => { + const feedback = engine._collectFeedback([], 'reply-id'); + expect(feedback).toEqual([]); + }); + + it('returns empty array when reply not found', () => { + const conversationEntries = [ + { id: 'msg1', role: 'user', text: 'Message' } + ]; + + const feedback = engine._collectFeedback(conversationEntries, 'nonexistent-reply'); + + expect(feedback).toEqual([]); + }); + }); + + describe('_deriveTimeWindow', () => { + it('derives time window from conversation timestamps', () => { + const conversationEntries = [ + { createdAt: 1000 }, + { createdAt: 2000 }, + { createdAt: 3000 } + ]; + + const window = engine._deriveTimeWindow(conversationEntries, 4000, 500); + + expect(window).toBeTruthy(); + expect(window.start).toBeLessThan(1000); + expect(window.end).toBeGreaterThan(4000); + }); + + it('includes reply and parent timestamps', () => { + const conversationEntries = []; + const window = engine._deriveTimeWindow(conversationEntries, 5000, 1000); + + expect(window).toBeTruthy(); + expect(window.start).toBeLessThan(1000); + expect(window.end).toBeGreaterThan(5000); + }); + + it('returns null for empty data', () => { + const window = engine._deriveTimeWindow([], null, null); + expect(window).toBe(null); + }); + + it('adds padding to time window', () => { + const conversationEntries = [{ createdAt: 10000 }]; + const window = engine._deriveTimeWindow(conversationEntries, 10000, 10000); + + const padding = 15 * 60 * 1000; // 15 minutes + expect(window.start).toBe(10000 - padding); + expect(window.end).toBe(10000 + padding); + }); + }); + + describe('_collectSignalsForInteraction', () => { + it('collects signals within time window', () => { + const now = Date.now(); + const replyMemory = { id: 'reply-1', createdAt: now }; + const timeWindow = { start: now - 30 * 60 * 1000, end: now + 30 * 60 * 1000 }; + + const allMemories = [ + { + id: 'signal-1', + createdAt: now - 10 * 60 * 1000, + content: { + type: 'zap_received', + data: { summary: 'Received 1000 sats' } + } + }, + { + id: 'signal-2', + createdAt: now - 5 * 60 * 1000, + content: { + type: 'engagement_metrics', + text: 'High engagement detected' + } + } + ]; + + const signals = engine._collectSignalsForInteraction(allMemories, replyMemory, timeWindow); + + expect(signals.length).toBe(2); + expect(signals[0]).toContain('zap_received'); + expect(signals[1]).toContain('engagement_metrics'); + }); + + it('excludes signals outside time window', () => { + const now = Date.now(); + const replyMemory = { id: 'reply-1', createdAt: now }; + const timeWindow = { start: now - 10 * 60 * 1000, end: now + 10 * 60 * 1000 }; + + const allMemories = [ + { + id: 'too-old', + createdAt: now - 60 * 60 * 1000, + content: { type: 'old_signal', text: 'Too old' } + }, + { + id: 'in-window', + createdAt: now - 5 * 60 * 1000, + content: { type: 'good_signal', text: 'Within window' } + } + ]; + + const signals = engine._collectSignalsForInteraction(allMemories, replyMemory, timeWindow); + + expect(signals.length).toBe(1); + expect(signals[0]).toContain('good_signal'); + }); + + it('excludes self_reflection and nostr_thread_context types', () => { + const now = Date.now(); + const replyMemory = { id: 'reply-1', createdAt: now }; + const timeWindow = { start: now - 30 * 60 * 1000, end: now + 30 * 60 * 1000 }; + + const allMemories = [ + { + id: 'excluded-1', + createdAt: now - 1000, + content: { type: 'self_reflection', text: 'Should be excluded' } + }, + { + id: 'excluded-2', + createdAt: now - 2000, + content: { type: 'nostr_thread_context', text: 'Should be excluded' } + } + ]; + + const signals = engine._collectSignalsForInteraction(allMemories, replyMemory, timeWindow); + + expect(signals.length).toBe(0); + }); + + it('limits signals to 5', () => { + const now = Date.now(); + const replyMemory = { id: 'reply-1', createdAt: now }; + const timeWindow = { start: now - 30 * 60 * 1000, end: now + 30 * 60 * 1000 }; + + const allMemories = Array.from({ length: 10 }, (_, i) => ({ + id: `signal-${i}`, + createdAt: now - i * 1000, + content: { type: `signal_type_${i}`, text: `Signal ${i}` } + })); + + const signals = engine._collectSignalsForInteraction(allMemories, replyMemory, timeWindow); + + expect(signals.length).toBe(5); + }); + + it('returns empty array for empty memories', () => { + const replyMemory = { id: 'reply', createdAt: Date.now() }; + const signals = engine._collectSignalsForInteraction([], replyMemory, null); + expect(signals).toEqual([]); + }); + }); + + describe('_collectGlobalSignals', () => { + it('collects diverse signal types', () => { + const now = Date.now(); + const sortedMemories = [ + { + id: 'signal-1', + createdAt: now - 5000, + roomId: 'room-1', + content: { + type: 'zap_received', + data: { summary: 'Received zap' } + } + }, + { + id: 'signal-2', + createdAt: now - 3000, + roomId: 'room-2', + content: { + type: 'engagement_update', + text: 'High engagement' + } + } + ]; + + const signals = engine._collectGlobalSignals(sortedMemories); + + expect(signals.length).toBe(2); + expect(signals[0]).toContain('zap_received'); + expect(signals[1]).toContain('engagement_update'); + }); + + it('excludes self_reflection type', () => { + const sortedMemories = [ + { + id: 'reflection', + createdAt: Date.now(), + content: { type: 'self_reflection', text: 'Should be excluded' } + }, + { + id: 'signal', + createdAt: Date.now(), + content: { type: 'valid_signal', text: 'Should be included' } + } + ]; + + const signals = engine._collectGlobalSignals(sortedMemories); + + expect(signals.length).toBe(1); + expect(signals[0]).toContain('valid_signal'); + }); + + it('deduplicates by type and roomId', () => { + const sortedMemories = [ + { + id: 'signal-1', + createdAt: Date.now(), + roomId: 'room-1', + content: { type: 'zap_received', text: 'First zap' } + }, + { + id: 'signal-2', + createdAt: Date.now(), + roomId: 'room-1', + content: { type: 'zap_received', text: 'Second zap' } + } + ]; + + const signals = engine._collectGlobalSignals(sortedMemories); + + expect(signals.length).toBe(1); + }); + + it('limits signals to 8', () => { + const sortedMemories = Array.from({ length: 20 }, (_, i) => ({ + id: `signal-${i}`, + createdAt: Date.now() - i * 1000, + roomId: `room-${i}`, + content: { type: `type_${i}`, text: `Signal ${i}` } + })); + + const signals = engine._collectGlobalSignals(sortedMemories); + + expect(signals.length).toBe(8); + }); + + it('includes timestamp in signal when available', () => { + const timestamp = Date.parse('2025-10-05T10:00:00.000Z'); + const sortedMemories = [ + { + id: 'signal-1', + createdAt: timestamp, + content: { type: 'test_signal', text: 'Test' } + } + ]; + + const signals = engine._collectGlobalSignals(sortedMemories); + + expect(signals[0]).toContain('2025-10-05T10:00:00.000Z'); + }); + + it('returns empty array for empty input', () => { + const signals = engine._collectGlobalSignals([]); + expect(signals).toEqual([]); + }); + + it('returns empty array for null input', () => { + const signals = engine._collectGlobalSignals(null); + expect(signals).toEqual([]); + }); + }); +});