feat(memory): add working memory read/write with TTL expiry

This commit is contained in:
William Valentin
2026-02-25 12:54:39 -08:00
parent 64ebc636c1
commit 8412e3b096
2 changed files with 162 additions and 0 deletions
+76
View File
@@ -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 });
});
});
+86
View File
@@ -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 };
}