feat: add persistent memory system (Phase 2)
Implement file-based persistent memory with read/write/search tools: - MemoryStore with namespace-scoped JSON storage - memory-read, memory-write, memory-search builtin tools - Auto-extraction of facts during context compaction - Configurable via memory.enabled, memory.dir, memory.max_context_tokens
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('MemoryStore', () => {
|
||||
let dir: string;
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'flynn-memory-test-'));
|
||||
store = new MemoryStore({ dir, maxContextTokens: 4096 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('returns empty string for non-existent namespace', () => {
|
||||
const result = store.read('nonexistent');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returns file contents for existing namespace', () => {
|
||||
// Pre-populate a memory file directly on disk
|
||||
const filePath = join(dir, 'notes.md');
|
||||
writeFileSync(filePath, 'Remember to check logs daily');
|
||||
|
||||
const result = store.read('notes');
|
||||
expect(result).toBe('Remember to check logs daily');
|
||||
});
|
||||
|
||||
it('handles nested namespaces (sessions/abc123)', () => {
|
||||
// Pre-populate a nested memory file
|
||||
const nestedDir = join(dir, 'sessions');
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
writeFileSync(join(nestedDir, 'abc123.md'), 'Session context data');
|
||||
|
||||
const result = store.read('sessions/abc123');
|
||||
expect(result).toBe('Session context data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
it('creates file and directories on first write', () => {
|
||||
store.write('projects/alpha', 'Initial content', 'replace');
|
||||
|
||||
const filePath = join(dir, 'projects', 'alpha.md');
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('Initial content');
|
||||
});
|
||||
|
||||
it('appends content with newline separator in append mode', () => {
|
||||
store.write('journal', 'First entry', 'replace');
|
||||
store.write('journal', 'Second entry', 'append');
|
||||
|
||||
const result = store.read('journal');
|
||||
expect(result).toContain('First entry');
|
||||
expect(result).toContain('Second entry');
|
||||
|
||||
// Verify entries are separated by newline
|
||||
const lines = result.split('\n').filter((l: string) => l.trim().length > 0);
|
||||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('replaces content in replace mode', () => {
|
||||
store.write('config', 'Old content', 'replace');
|
||||
store.write('config', 'New content', 'replace');
|
||||
|
||||
const result = store.read('config');
|
||||
expect(result).toContain('New content');
|
||||
expect(result).not.toContain('Old content');
|
||||
});
|
||||
|
||||
it('handles nested namespace paths', () => {
|
||||
store.write('deep/nested/path', 'Deep content', 'replace');
|
||||
|
||||
const result = store.read('deep/nested/path');
|
||||
expect(result).toContain('Deep content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(() => {
|
||||
store.write('notes', 'The quick brown fox jumps over the lazy dog\nAnother line of text\nFox sightings are common here', 'replace');
|
||||
store.write('journal', 'Today I saw a fox in the garden\nIt was a beautiful day\nThe weather was sunny', 'replace');
|
||||
store.write('config', 'No relevant animals mentioned\nJust some settings here', 'replace');
|
||||
});
|
||||
|
||||
it('finds matching lines case-insensitively', () => {
|
||||
const results = store.search('fox');
|
||||
|
||||
// Should match lines containing "fox" regardless of case
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const contents = results.map((r) => r.content.toLowerCase());
|
||||
for (const content of contents) {
|
||||
expect(content).toContain('fox');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns context lines around matches', () => {
|
||||
const results = store.search('fox');
|
||||
|
||||
// Each result should have a context field with surrounding text
|
||||
for (const result of results) {
|
||||
expect(result.context).toBeDefined();
|
||||
expect(result.context.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('searches across multiple namespaces', () => {
|
||||
const results = store.search('fox');
|
||||
|
||||
// Should find matches in both 'notes' and 'journal' namespaces
|
||||
const namespaces = new Set(results.map((r) => r.namespace));
|
||||
expect(namespaces.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no matches', () => {
|
||||
const results = store.search('xyznonexistent');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listNamespaces', () => {
|
||||
it('returns empty array when no files exist', () => {
|
||||
const namespaces = store.listNamespaces();
|
||||
expect(namespaces).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists all namespaces from existing files', () => {
|
||||
store.write('notes', 'Some notes', 'replace');
|
||||
store.write('journal', 'A journal entry', 'replace');
|
||||
store.write('config', 'Configuration data', 'replace');
|
||||
|
||||
const namespaces = store.listNamespaces();
|
||||
|
||||
expect(namespaces).toContain('notes');
|
||||
expect(namespaces).toContain('journal');
|
||||
expect(namespaces).toContain('config');
|
||||
expect(namespaces).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('includes nested namespaces', () => {
|
||||
store.write('sessions/abc123', 'Session data', 'replace');
|
||||
store.write('sessions/def456', 'Another session', 'replace');
|
||||
store.write('global', 'Global notes', 'replace');
|
||||
|
||||
const namespaces = store.listNamespaces();
|
||||
|
||||
expect(namespaces).toContain('sessions/abc123');
|
||||
expect(namespaces).toContain('sessions/def456');
|
||||
expect(namespaces).toContain('global');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextForPrompt', () => {
|
||||
it('returns empty string when no memory files exist', () => {
|
||||
const context = store.getContextForPrompt();
|
||||
expect(context).toBe('');
|
||||
});
|
||||
|
||||
it('includes user and global memory under headings', () => {
|
||||
store.write('user', 'User prefers concise answers', 'replace');
|
||||
store.write('global', 'System-wide knowledge base', 'replace');
|
||||
|
||||
const context = store.getContextForPrompt();
|
||||
|
||||
// Should contain both memory sections with some form of heading
|
||||
expect(context).toContain('User Memory');
|
||||
expect(context).toContain('User prefers concise answers');
|
||||
expect(context).toContain('Global Memory');
|
||||
expect(context).toContain('System-wide knowledge base');
|
||||
});
|
||||
|
||||
it('truncates content to stay within maxContextTokens', () => {
|
||||
// Create a store with a very small token limit (50 tokens ≈ 200 chars)
|
||||
const smallStore = new MemoryStore({ dir, maxContextTokens: 50 });
|
||||
|
||||
// Write a large amount of content to user and global (which getContextForPrompt reads)
|
||||
const longContent = 'A'.repeat(5000);
|
||||
smallStore.write('user', longContent, 'replace');
|
||||
smallStore.write('global', longContent, 'replace');
|
||||
|
||||
const context = smallStore.getContextForPrompt();
|
||||
|
||||
// 50 tokens * 4 chars/token = 200 chars max
|
||||
expect(context.length).toBeLessThanOrEqual(200);
|
||||
expect(context.length).toBeLessThan(longContent.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user