fbb744ba6b
Implements `StructuredMemoryManager` to track conversation summaries, user preferences, and learned facts across sessions. Includes: - Configurable storage limits with automatic enforcement - Heuristic extraction of preferences and facts from messages - Memory context injection into system prompts - Full test coverage for all manager operations
606 lines
19 KiB
TypeScript
606 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|