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