feat(memory): add working memory read/write with TTL expiry
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user