feat(memory): add EventMemoryService with smart selection and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 21:52:46 +00:00
parent ba487839fd
commit 1f5cafe2fd
2 changed files with 255 additions and 0 deletions
@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from 'vitest';
import type { EntityId, MemoryEvent } from '@dflike/shared';
import { createEventMemoryService } from '../eventMemoryService.js';
function makeEvent(overrides: Partial<Omit<MemoryEvent, 'id'>> = {}): Omit<MemoryEvent, 'id'> {
return {
type: 'social_positive',
tick: 100,
detail: 'had a friendly chat',
...overrides,
};
}
describe('EventMemoryService', () => {
it('records events with auto-incrementing IDs', () => {
const service = createEventMemoryService();
const e1 = service.record(1, makeEvent());
const e2 = service.record(1, makeEvent());
expect(e1.id).toBe(1);
expect(e2.id).toBe(2);
});
it('returns empty array for unknown entity', () => {
const service = createEventMemoryService();
expect(service.getEvents(999)).toEqual([]);
expect(service.getRecentEvents(999, 5)).toEqual([]);
});
it('caps buffer at maxEvents per entity', () => {
const service = createEventMemoryService(10);
for (let i = 0; i < 15; i++) {
service.record(1, makeEvent({ tick: i }));
}
const events = service.getEvents(1);
expect(events.length).toBe(10);
// Oldest 5 events (tick 0-4) should have been dropped
expect(events[0].tick).toBe(5);
expect(events[9].tick).toBe(14);
});
it('getRecentEvents returns last N events', () => {
const service = createEventMemoryService();
for (let i = 0; i < 10; i++) {
service.record(1, makeEvent({ tick: i }));
}
const recent = service.getRecentEvents(1, 3);
expect(recent.length).toBe(3);
expect(recent[0].tick).toBe(7);
expect(recent[1].tick).toBe(8);
expect(recent[2].tick).toBe(9);
});
it('maintains separate buffers per entity', () => {
const service = createEventMemoryService();
service.record(1, makeEvent({ detail: 'entity 1 event' }));
service.record(2, makeEvent({ detail: 'entity 2 event' }));
service.record(1, makeEvent({ detail: 'entity 1 second event' }));
expect(service.getEvents(1).length).toBe(2);
expect(service.getEvents(2).length).toBe(1);
expect(service.getEvents(1)[0].detail).toBe('entity 1 event');
expect(service.getEvents(2)[0].detail).toBe('entity 2 event');
});
it('onEventRecorded callback fires with entityId and event', () => {
const service = createEventMemoryService();
const recorded: Array<{ entityId: EntityId; event: MemoryEvent }> = [];
service.onEventRecorded = (entityId, event) => recorded.push({ entityId, event });
service.record(42, makeEvent({ detail: 'callback test' }));
expect(recorded.length).toBe(1);
expect(recorded[0].entityId).toBe(42);
expect(recorded[0].event.detail).toBe('callback test');
expect(recorded[0].event.id).toBe(1);
});
describe('selectForPrompt', () => {
it('returns most recent by default', () => {
const service = createEventMemoryService();
for (let i = 0; i < 10; i++) {
service.record(1, makeEvent({ tick: i * 10 }));
}
const selected = service.selectForPrompt(1, { maxCount: 3 });
expect(selected.length).toBe(3);
// Should be the 3 most recent (highest tick = highest recency score)
const ticks = selected.map((e) => e.tick);
expect(ticks).toContain(90);
expect(ticks).toContain(80);
expect(ticks).toContain(70);
});
it('boosts events matching preferTypes', () => {
const service = createEventMemoryService();
// Insert a mid-age tier_change among social_positive events
service.record(1, makeEvent({ tick: 10, type: 'social_positive' }));
service.record(1, makeEvent({ tick: 20, type: 'social_positive' }));
service.record(1, makeEvent({ tick: 30, type: 'tier_change', detail: 'became friends' }));
service.record(1, makeEvent({ tick: 40, type: 'social_positive' }));
service.record(1, makeEvent({ tick: 50, type: 'social_positive' }));
service.record(1, makeEvent({ tick: 60, type: 'social_positive' }));
const selected = service.selectForPrompt(1, {
maxCount: 3,
preferTypes: ['tier_change'],
});
// The tier_change event should be boosted above some more-recent events
const types = selected.map((e) => e.type);
expect(types).toContain('tier_change');
});
it('boosts events matching preferEntityId', () => {
const service = createEventMemoryService();
// Insert a mid-age event about entity 99 among events about others
service.record(1, makeEvent({ tick: 10, otherEntityId: 1 }));
service.record(1, makeEvent({ tick: 20, otherEntityId: 2 }));
service.record(1, makeEvent({ tick: 30, otherEntityId: 99, detail: 'with entity 99' }));
service.record(1, makeEvent({ tick: 40, otherEntityId: 3 }));
service.record(1, makeEvent({ tick: 50, otherEntityId: 4 }));
service.record(1, makeEvent({ tick: 60, otherEntityId: 5 }));
const selected = service.selectForPrompt(1, {
maxCount: 3,
preferEntityId: 99,
});
// The event about entity 99 should be boosted above some more-recent events
const details = selected.map((e) => e.detail);
expect(details).toContain('with entity 99');
});
it('returns empty for unknown entity', () => {
const service = createEventMemoryService();
const selected = service.selectForPrompt(999, { maxCount: 5 });
expect(selected).toEqual([]);
});
it('returns all if fewer than maxCount', () => {
const service = createEventMemoryService();
service.record(1, makeEvent({ tick: 10 }));
service.record(1, makeEvent({ tick: 20 }));
const selected = service.selectForPrompt(1, { maxCount: 10 });
expect(selected.length).toBe(2);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
import type { EntityId, MemoryEvent, MemoryEventType } from '@dflike/shared';
export interface EventMemoryService {
record(entityId: EntityId, event: Omit<MemoryEvent, 'id'>): MemoryEvent;
getEvents(entityId: EntityId): MemoryEvent[];
getRecentEvents(entityId: EntityId, count: number): MemoryEvent[];
selectForPrompt(entityId: EntityId, opts: {
maxCount: number;
preferTypes?: MemoryEventType[];
preferEntityId?: EntityId;
}): MemoryEvent[];
onEventRecorded: ((entityId: EntityId, event: MemoryEvent) => void) | null;
}
export function createEventMemoryService(maxEvents = 50): EventMemoryService {
const buffers = new Map<EntityId, MemoryEvent[]>();
let nextId = 1;
function getBuffer(entityId: EntityId): MemoryEvent[] {
let buf = buffers.get(entityId);
if (!buf) {
buf = [];
buffers.set(entityId, buf);
}
return buf;
}
const service: EventMemoryService = {
onEventRecorded: null,
record(entityId: EntityId, eventData: Omit<MemoryEvent, 'id'>): MemoryEvent {
const event: MemoryEvent = {
id: nextId++,
...eventData,
};
const buf = getBuffer(entityId);
buf.push(event);
if (buf.length > maxEvents) {
buf.shift();
}
service.onEventRecorded?.(entityId, event);
return event;
},
getEvents(entityId: EntityId): MemoryEvent[] {
const buf = buffers.get(entityId);
if (!buf) return [];
return [...buf];
},
getRecentEvents(entityId: EntityId, count: number): MemoryEvent[] {
const buf = buffers.get(entityId);
if (!buf) return [];
return buf.slice(-count);
},
selectForPrompt(entityId: EntityId, opts: {
maxCount: number;
preferTypes?: MemoryEventType[];
preferEntityId?: EntityId;
}): MemoryEvent[] {
const buf = buffers.get(entityId);
if (!buf || buf.length === 0) return [];
if (buf.length <= opts.maxCount) {
return [...buf];
}
// Find tick range for recency scoring
const oldestTick = buf[0].tick;
const newestTick = buf[buf.length - 1].tick;
const tickRange = newestTick - oldestTick;
const scored = buf.map((event) => {
// Recency: linear 0.0 (oldest) to 1.0 (newest), with base of 0.1
// so even the oldest event gets a nonzero score that multipliers can boost
let score = tickRange > 0
? 0.1 + 0.9 * (event.tick - oldestTick) / tickRange
: 1.0;
// Type match boost
if (opts.preferTypes && opts.preferTypes.includes(event.type)) {
score *= 2;
}
// Entity match boost
if (opts.preferEntityId != null && event.otherEntityId === opts.preferEntityId) {
score *= 3;
}
return { event, score };
});
// Sort descending by score
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, opts.maxCount).map((s) => s.event);
},
};
return service;
}