Files
obsidian_ollama/tests/structured-memory.test.ts
T
fegger fbb744ba6b Add structured memory system for persistent agent context
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
2026-05-20 22:18:33 +02:00

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);
});
});
});