feat(memory): add EventMemoryService with smart selection and tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user