diff --git a/src/memory/workingMemory.test.ts b/src/memory/workingMemory.test.ts new file mode 100644 index 0000000..f9749ea --- /dev/null +++ b/src/memory/workingMemory.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { join } from 'path'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { MemoryStore } from './store.js'; +import { writeWorkingMemory, readWorkingMemory } from './workingMemory.js'; + +function makeStore(): { store: MemoryStore; dir: string } { + const dir = mkdtempSync(join(tmpdir(), 'wm-test-')); + const store = new MemoryStore({ dir, maxContextTokens: 2000 }); + return { store, dir }; +} + +describe('writeWorkingMemory', () => { + it('writes a file with Updated/Expires headers', () => { + const { store, dir } = makeStore(); + writeWorkingMemory(store, 'user/working', 'some content', 14, 1000); + const raw = store.read('user/working'); + expect(raw).toContain('# Working Memory'); + expect(raw).toContain('Updated:'); + expect(raw).toContain('Expires:'); + expect(raw).toContain('some content'); + rmSync(dir, { recursive: true }); + }); + + it('truncates content to token budget', () => { + const { store, dir } = makeStore(); + const longContent = 'x'.repeat(10000); + writeWorkingMemory(store, 'user/working', longContent, 14, 100); + const raw = store.read('user/working'); + // 100 tokens * 4 chars = 400 chars budget for content + const contentPart = raw.split('\n\n').slice(1).join('\n\n'); + expect(contentPart.length).toBeLessThanOrEqual(400 + 10); // small tolerance + rmSync(dir, { recursive: true }); + }); +}); + +describe('readWorkingMemory', () => { + it('returns null when file does not exist', () => { + const { store, dir } = makeStore(); + expect(readWorkingMemory(store, 'user/working')).toBeNull(); + rmSync(dir, { recursive: true }); + }); + + it('returns content when not expired', () => { + const { store, dir } = makeStore(); + writeWorkingMemory(store, 'user/working', 'hello world', 14, 1000); + const result = readWorkingMemory(store, 'user/working'); + expect(result).not.toBeNull(); + expect(result!.content).toBe('hello world'); + rmSync(dir, { recursive: true }); + }); + + it('returns null when expired', () => { + const { store, dir } = makeStore(); + // Write a file with a hardcoded past expiry date for deterministic testing + const expiredFile = [ + '# Working Memory', + 'Updated: 2025-01-01T00:00:00Z', + 'Expires: 2025-01-02T00:00:00Z', + '', + 'stale content', + ].join('\n'); + store.write('user/working', expiredFile, 'replace'); + const result = readWorkingMemory(store, 'user/working'); + expect(result).toBeNull(); + rmSync(dir, { recursive: true }); + }); + + it('returns null for malformed file', () => { + const { store, dir } = makeStore(); + store.write('user/working', 'no headers here', 'replace'); + expect(readWorkingMemory(store, 'user/working')).toBeNull(); + rmSync(dir, { recursive: true }); + }); +}); diff --git a/src/memory/workingMemory.ts b/src/memory/workingMemory.ts new file mode 100644 index 0000000..665f660 --- /dev/null +++ b/src/memory/workingMemory.ts @@ -0,0 +1,86 @@ +import type { MemoryStore } from './store.js'; + +export interface WorkingMemoryEntry { + content: string; + updatedAt: Date; + expiresAt: Date; +} + +const HEADER_PREFIX = '# Working Memory\n'; + +/** + * Write a compaction summary to the working memory namespace. + * Content is capped at maxTokens (estimated at 4 chars/token). + * The file format includes Updated/Expires timestamps for lazy expiry checks. + */ +export function writeWorkingMemory( + store: MemoryStore, + namespace: string, + content: string, + ttlDays: number, + maxTokens: number, +): void { + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000); + const maxChars = maxTokens * 4; + const truncatedContent = content.length > maxChars ? content.slice(0, maxChars) : content; + + const file = [ + '# Working Memory', + `Updated: ${now.toISOString()}`, + `Expires: ${expiresAt.toISOString()}`, + '', + truncatedContent, + ].join('\n'); + + store.write(namespace, file, 'replace'); +} + +/** + * Read working memory. Returns null if the file is absent, malformed, or expired. + * MemoryStore.read() returns '' for missing files, so falsy check works. + * Expiry is checked lazily here — no background cleanup needed. + */ +export function readWorkingMemory( + store: MemoryStore, + namespace: string, +): WorkingMemoryEntry | null { + const raw = store.read(namespace); + if (!raw) { + return null; + } + + if (!raw.startsWith(HEADER_PREFIX)) { + return null; + } + + const lines = raw.split('\n'); + let updatedAt: Date | null = null; + let expiresAt: Date | null = null; + let contentStartLine = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('Updated: ')) { + updatedAt = new Date(line.slice('Updated: '.length)); + } else if (line.startsWith('Expires: ')) { + expiresAt = new Date(line.slice('Expires: '.length)); + } else if (line === '' && expiresAt !== null) { + contentStartLine = i + 1; + break; + } + } + + if (!updatedAt || !expiresAt || isNaN(expiresAt.getTime())) { + return null; + } + + if (expiresAt.getTime() <= Date.now()) { + console.debug('[Flynn:working-memory] Working memory expired, skipping injection'); + return null; + } + + const content = lines.slice(contentStartLine).join('\n').trim(); + + return { content, updatedAt, expiresAt }; +}