diff --git a/server/src/llm/__tests__/eventMemoryService.test.ts b/server/src/llm/__tests__/eventMemoryService.test.ts new file mode 100644 index 0000000..69d186a --- /dev/null +++ b/server/src/llm/__tests__/eventMemoryService.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/server/src/llm/eventMemoryService.ts b/server/src/llm/eventMemoryService.ts new file mode 100644 index 0000000..ca68a35 --- /dev/null +++ b/server/src/llm/eventMemoryService.ts @@ -0,0 +1,105 @@ +import type { EntityId, MemoryEvent, MemoryEventType } from '@dflike/shared'; + +export interface EventMemoryService { + record(entityId: EntityId, event: Omit): 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(); + 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 { + 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; +}