import { StructuredMemoryManager, createDefaultStructuredMemoryData, } from '../src/structured-memory'; import type { StructuredMemoryConfig, ConversationSummary, UserPreference, LearnedFact, OllamaMessage, } from '../src/types'; describe('createDefaultStructuredMemoryData', () => { it('should return empty arrays for all memory types', () => { const data = createDefaultStructuredMemoryData(); expect(data.conversationSummaries).toEqual([]); expect(data.userPreferences).toEqual([]); expect(data.learnedFacts).toEqual([]); }); }); describe('StructuredMemoryManager', () => { const defaultConfig: StructuredMemoryConfig = { enabled: true, maxSummaries: 3, maxPreferences: 3, maxFacts: 3, }; let manager: StructuredMemoryManager; beforeEach(() => { manager = new StructuredMemoryManager(defaultConfig); }); describe('constructor', () => { it('should initialize with empty data when no initial data provided', () => { expect(manager.getConversationSummaries()).toEqual([]); expect(manager.getUserPreferences()).toEqual([]); expect(manager.getLearnedFacts()).toEqual([]); }); it('should initialize with provided data', () => { const initialData = createDefaultStructuredMemoryData(); initialData.userPreferences.push({ key: 'theme', value: 'dark', timestamp: Date.now(), source: 'explicit', }); const m = new StructuredMemoryManager(defaultConfig, initialData); expect(m.getUserPreferences()).toHaveLength(1); }); }); describe('loadData and getData', () => { it('should load and return data round-trip', () => { const summary: ConversationSummary = { id: 's1', timestamp: 1000, topic: 'Test', summary: 'A test summary', keyPoints: ['point1'], }; manager.addConversationSummary(summary); const loaded = manager.getData(); expect(loaded.conversationSummaries).toHaveLength(1); const newManager = new StructuredMemoryManager(defaultConfig); newManager.loadData(loaded); expect(newManager.getConversationSummaries()).toHaveLength(1); }); }); describe('updateConfig', () => { it('should enforce new limits after config update', () => { for (let i = 0; i < 5; i++) { manager.addConversationSummary({ id: `s${i}`, timestamp: i, topic: `Topic ${i}`, summary: `Summary ${i}`, keyPoints: [`point ${i}`], }); } // Already limited to default max of 3 expect(manager.getConversationSummaries()).toHaveLength(3); manager.updateConfig({ ...defaultConfig, maxSummaries: 2 }); expect(manager.getConversationSummaries()).toHaveLength(2); }); it('should disable writes when enabled becomes false', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'Test', summary: 'Test', keyPoints: ['test'], }); expect(manager.getConversationSummaries()).toHaveLength(0); }); }); describe('addConversationSummary', () => { it('should add a summary', () => { const summary: ConversationSummary = { id: 's1', timestamp: 1, topic: 'Topic', summary: 'Summary', keyPoints: ['k1'], }; manager.addConversationSummary(summary); expect(manager.getConversationSummaries()).toContainEqual(summary); }); it('should enforce maxSummaries limit keeping newest', () => { for (let i = 0; i < 5; i++) { manager.addConversationSummary({ id: `s${i}`, timestamp: i, topic: `Topic ${i}`, summary: `Summary ${i}`, keyPoints: [`point ${i}`], }); } const summaries = manager.getConversationSummaries(); expect(summaries).toHaveLength(3); expect(summaries[0].id).toBe('s2'); expect(summaries[2].id).toBe('s4'); }); it('should not add when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'Topic', summary: 'Summary', keyPoints: ['k1'], }); expect(manager.getConversationSummaries()).toHaveLength(0); }); }); describe('addUserPreference', () => { it('should add a preference', () => { const pref: UserPreference = { key: 'theme', value: 'dark', timestamp: 1, source: 'explicit', }; manager.addUserPreference(pref); expect(manager.getUserPreferences()).toContainEqual(pref); }); it('should update existing preference by key', () => { manager.addUserPreference({ key: 'theme', value: 'dark', timestamp: 1, source: 'explicit', }); manager.addUserPreference({ key: 'theme', value: 'light', timestamp: 2, source: 'explicit', }); const prefs = manager.getUserPreferences(); expect(prefs).toHaveLength(1); expect(prefs[0].value).toBe('light'); }); it('should enforce maxPreferences keeping most recent', () => { for (let i = 0; i < 5; i++) { manager.addUserPreference({ key: `pref-${i}`, value: `value-${i}`, timestamp: i, source: 'explicit', }); } const prefs = manager.getUserPreferences(); expect(prefs).toHaveLength(3); // Most recent 3 (timestamps 2, 3, 4) expect(prefs.map((p) => p.timestamp)).toEqual([4, 3, 2]); }); it('should not add when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); manager.addUserPreference({ key: 'theme', value: 'dark', timestamp: 1, source: 'explicit', }); expect(manager.getUserPreferences()).toHaveLength(0); }); }); describe('addLearnedFact', () => { it('should add a fact', () => { const fact: LearnedFact = { id: 'f1', timestamp: 1, content: 'The sky is blue.', category: 'general', confidence: 0.9, }; manager.addLearnedFact(fact); expect(manager.getLearnedFacts()).toContainEqual(fact); }); it('should deduplicate facts by content (case-insensitive)', () => { manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'The sky is blue.', category: 'general', confidence: 0.5, }); manager.addLearnedFact({ id: 'f2', timestamp: 2, content: ' the sky is blue. ', category: 'topic', confidence: 0.8, }); const facts = manager.getLearnedFacts(); expect(facts).toHaveLength(1); expect(facts[0].confidence).toBe(0.8); expect(facts[0].category).toBe('topic'); }); it('should enforce maxFacts keeping highest confidence', () => { for (let i = 0; i < 5; i++) { manager.addLearnedFact({ id: `f${i}`, timestamp: i, content: `Fact ${i}`, category: 'general', confidence: 0.1 * i, }); } const facts = manager.getLearnedFacts(); expect(facts).toHaveLength(3); // Highest confidence facts (0.4, 0.3, 0.2) expect(facts.map((f) => f.confidence)).toEqual([0.4, expect.closeTo(0.3, 10), 0.2]); }); it('should not add when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'Fact', category: 'general', confidence: 0.9, }); expect(manager.getLearnedFacts()).toHaveLength(0); }); }); describe('getUserPreference', () => { it('should return preference by key', () => { manager.addUserPreference({ key: 'theme', value: 'dark', timestamp: 1, source: 'explicit', }); expect(manager.getUserPreference('theme')?.value).toBe('dark'); }); it('should return undefined for missing key', () => { expect(manager.getUserPreference('missing')).toBeUndefined(); }); }); describe('getLearnedFactsByCategory', () => { it('should filter facts by category', () => { manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'Vault has a /projects folder.', category: 'vault_structure', confidence: 0.8, }); manager.addLearnedFact({ id: 'f2', timestamp: 2, content: 'User likes markdown.', category: 'general', confidence: 0.7, }); expect(manager.getLearnedFactsByCategory('vault_structure')).toHaveLength(1); expect(manager.getLearnedFactsByCategory('general')).toHaveLength(1); expect(manager.getLearnedFactsByCategory('topic')).toHaveLength(0); }); }); describe('clear methods', () => { it('should clear conversation summaries', () => { manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'T', summary: 'S', keyPoints: ['k'], }); manager.clearConversationSummaries(); expect(manager.getConversationSummaries()).toHaveLength(0); }); it('should clear user preferences', () => { manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' }); manager.clearUserPreferences(); expect(manager.getUserPreferences()).toHaveLength(0); }); it('should clear learned facts', () => { manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'C', category: 'general', confidence: 0.5, }); manager.clearLearnedFacts(); expect(manager.getLearnedFacts()).toHaveLength(0); }); it('should clear all memory', () => { manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'T', summary: 'S', keyPoints: ['k'], }); manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' }); manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'C', category: 'general', confidence: 0.5, }); manager.clearAll(); expect(manager.getConversationSummaries()).toHaveLength(0); expect(manager.getUserPreferences()).toHaveLength(0); expect(manager.getLearnedFacts()).toHaveLength(0); }); }); describe('buildMemoryContext', () => { it('should return empty string when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); expect(manager.buildMemoryContext()).toBe(''); }); it('should return empty string when no memory exists', () => { expect(manager.buildMemoryContext()).toBe(''); }); it('should include conversation summaries', () => { manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'Test Topic', summary: 'We discussed testing.', keyPoints: ['testing is good'], }); const ctx = manager.buildMemoryContext(); expect(ctx).toContain('Past Conversations'); expect(ctx).toContain('Test Topic'); expect(ctx).toContain('We discussed testing.'); }); it('should include user preferences', () => { manager.addUserPreference({ key: 'theme', value: 'dark', timestamp: 1, source: 'explicit', }); const ctx = manager.buildMemoryContext(); expect(ctx).toContain('User Preferences'); expect(ctx).toContain('theme: dark'); }); it('should include learned facts above confidence threshold', () => { manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'Vault uses folders.', category: 'vault_structure', confidence: 0.6, }); manager.addLearnedFact({ id: 'f2', timestamp: 2, content: 'Low confidence fact.', category: 'general', confidence: 0.3, }); const ctx = manager.buildMemoryContext(); expect(ctx).toContain('Learned Facts'); expect(ctx).toContain('Vault uses folders.'); expect(ctx).not.toContain('Low confidence fact.'); }); it('should combine all sections', () => { manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'T', summary: 'S', keyPoints: ['k'], }); manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' }); manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'Fact', category: 'general', confidence: 0.9, }); const ctx = manager.buildMemoryContext(); expect(ctx).toContain('Past Conversations'); expect(ctx).toContain('User Preferences'); expect(ctx).toContain('Learned Facts'); }); it('should limit summaries to last 3', () => { for (let i = 0; i < 5; i++) { manager.addConversationSummary({ id: `s${i}`, timestamp: i, topic: `Topic ${i}`, summary: `Summary ${i}`, keyPoints: [`k${i}`], }); } const ctx = manager.buildMemoryContext(); expect(ctx).toContain('Topic 2'); expect(ctx).toContain('Topic 4'); expect(ctx).not.toContain('Topic 0'); }); it('should limit facts to last 10', () => { for (let i = 0; i < 15; i++) { manager.addLearnedFact({ id: `f${i}`, timestamp: i, content: `Fact ${i}`, category: 'general', confidence: 0.9, }); } const ctx = manager.buildMemoryContext(); const factMatches = ctx.match(/Fact \d+/g) ?? []; expect(factMatches.length).toBeLessThanOrEqual(10); }); }); describe('extractPreferencesFromMessage', () => { it('should extract "I prefer" statements', () => { const prefs = manager.extractPreferencesFromMessage('I prefer dark mode.'); expect(prefs.length).toBeGreaterThanOrEqual(1); expect(prefs[0].value).toContain('dark mode'); expect(prefs[0].source).toBe('inferred'); }); it('should extract "I like" statements', () => { const prefs = manager.extractPreferencesFromMessage('I like coffee in the morning.'); expect(prefs.length).toBeGreaterThanOrEqual(1); expect(prefs[0].value).toContain('coffee'); }); it('should extract "my favorite X is Y" statements', () => { const prefs = manager.extractPreferencesFromMessage('My favorite color is blue.'); expect(prefs.length).toBeGreaterThanOrEqual(1); expect(prefs[0].value).toContain('blue'); }); it('should return empty array when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); const prefs = manager.extractPreferencesFromMessage('I like blue.'); expect(prefs).toEqual([]); }); it('should return empty array for non-preference messages', () => { const prefs = manager.extractPreferencesFromMessage('What is the weather?'); expect(prefs).toEqual([]); }); }); describe('extractFactsFromMessage', () => { it('should extract vault folder paths', () => { const facts = manager.extractFactsFromMessage('Check the /projects/active/ folder.'); expect(facts.some((f) => f.content.includes('/projects/active/'))).toBe(true); expect(facts.some((f) => f.category === 'vault_structure')).toBe(true); }); it('should extract "X is a Y" topic facts', () => { const facts = manager.extractFactsFromMessage('Obsidian is a note-taking app.'); expect(facts.some((f) => f.content.includes('Obsidian is'))).toBe(true); expect(facts.some((f) => f.category === 'topic')).toBe(true); }); it('should not extract short subjects', () => { const facts = manager.extractFactsFromMessage('It is a thing.'); expect(facts).toEqual([]); }); it('should return empty array when disabled', () => { manager.updateConfig({ ...defaultConfig, enabled: false }); const facts = manager.extractFactsFromMessage('Obsidian is great.'); expect(facts).toEqual([]); }); }); describe('summarizeConversation', () => { it('should derive topic from first user message', () => { const messages: OllamaMessage[] = [ { role: 'user', content: 'Tell me about quantum physics please' }, { role: 'assistant', content: 'Quantum physics is fascinating. It deals with subatomic particles.', }, ]; const { topic, keyPoints } = manager.summarizeConversation(messages); expect(topic).toContain('Tell me about quantum physics'); expect(keyPoints.length).toBeGreaterThan(0); }); it('should fallback to Untitled when no user message', () => { const messages: OllamaMessage[] = [{ role: 'assistant', content: 'Hello there.' }]; const { topic } = manager.summarizeConversation(messages); expect(topic).toBe('Untitled conversation'); }); it('should limit key points to 3', () => { const messages: OllamaMessage[] = [ { role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Point one. Point two. Point three. Point four. Point five.', }, ]; const { keyPoints } = manager.summarizeConversation(messages); expect(keyPoints.length).toBeLessThanOrEqual(3); }); it('should extract sentences between 10 and 120 chars', () => { const messages: OllamaMessage[] = [ { role: 'user', content: 'Hi' }, { role: 'assistant', content: 'A. This is a reasonably sized sentence about topics.' }, ]; const { keyPoints } = manager.summarizeConversation(messages); expect(keyPoints.every((k) => k.length >= 10 && k.length < 120)).toBe(true); }); }); describe('immutability', () => { it('getConversationSummaries should return a copy', () => { manager.addConversationSummary({ id: 's1', timestamp: 1, topic: 'T', summary: 'S', keyPoints: ['k'], }); const summaries = manager.getConversationSummaries(); summaries.push({ id: 's2', timestamp: 2, topic: 'T2', summary: 'S2', keyPoints: ['k2'] }); expect(manager.getConversationSummaries()).toHaveLength(1); }); it('getUserPreferences should return a copy', () => { manager.addUserPreference({ key: 'k', value: 'v', timestamp: 1, source: 'explicit' }); const prefs = manager.getUserPreferences(); prefs.push({ key: 'k2', value: 'v2', timestamp: 2, source: 'explicit' }); expect(manager.getUserPreferences()).toHaveLength(1); }); it('getLearnedFacts should return a copy', () => { manager.addLearnedFact({ id: 'f1', timestamp: 1, content: 'C', category: 'general', confidence: 0.5, }); const facts = manager.getLearnedFacts(); facts.push({ id: 'f2', timestamp: 2, content: 'C2', category: 'general', confidence: 0.5 }); expect(manager.getLearnedFacts()).toHaveLength(1); }); }); });