From a95596452215e12c6ddc95b5ca18ffbce9bc9a4f Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 1 Apr 2026 00:48:40 +0900 Subject: [PATCH] feat: add memory selection and TTL decay --- runners/agent-runner/src/index.ts | 32 +++++--- runners/agent-runner/src/memory-selection.ts | 73 +++++++++++++++++ .../test/memory-selection.test.ts | 43 ++++++++++ src/db.test.ts | 61 +++++++++++++++ src/db.ts | 78 +++++++++++++++++++ 5 files changed, 276 insertions(+), 11 deletions(-) create mode 100644 runners/agent-runner/src/memory-selection.ts create mode 100644 runners/agent-runner/test/memory-selection.test.ts diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 18ab2e7..6c469fc 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -28,6 +28,7 @@ import { isReviewerMutatingShellCommand, isReviewerRuntime, } from './reviewer-runtime.js'; +import { selectCompactMemoriesFromSummary } from './memory-selection.js'; interface ContainerInput { prompt: string; @@ -284,17 +285,26 @@ async function persistCompactMemory(summary: string, sessionId: string): Promise try { const scopeKey = `room:${GROUP_FOLDER}`; - const file = writeHostTaskIpcFile({ - type: 'persist_memory', - scopeKind: 'room', - scopeKey, - content: trimSummary(normalized, 300), - keywords: [scopeKey], - source_kind: 'compact', - source_ref: sessionId ? `compact:${sessionId}` : null, - timestamp: new Date().toISOString(), - }); - log(`Persisted compact memory via IPC (${file})`); + const selected = selectCompactMemoriesFromSummary(normalized, scopeKey); + if (selected.length === 0) { + log('Skipped compact memory persist - no salient room memory found'); + return; + } + + for (const memory of selected) { + const file = writeHostTaskIpcFile({ + type: 'persist_memory', + scopeKind: 'room', + scopeKey, + content: trimSummary(memory.content, 300), + keywords: memory.keywords, + memory_kind: memory.memoryKind, + source_kind: 'compact', + source_ref: sessionId ? `compact:${sessionId}` : null, + timestamp: new Date().toISOString(), + }); + log(`Persisted compact memory via IPC (${file})`); + } } catch (err) { log( `Failed to persist compact memory via IPC: ${ diff --git a/runners/agent-runner/src/memory-selection.ts b/runners/agent-runner/src/memory-selection.ts new file mode 100644 index 0000000..52a715a --- /dev/null +++ b/runners/agent-runner/src/memory-selection.ts @@ -0,0 +1,73 @@ +export interface SelectedCompactMemory { + content: string; + keywords: string[]; + memoryKind: string | null; +} + +const MAX_SELECTED_MEMORIES = 3; + +const MEMORY_PATTERNS: Array<{ + kind: string | null; + regex: RegExp; + keyword: string; +}> = [ + { kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' }, + { kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' }, + { kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' }, + { kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' }, +]; + +function normalizeSentence(raw: string): string | null { + const normalized = raw + .trim() + .replace(/^[-*]\s*/, '') + .replace(/\s+/g, ' '); + return normalized ? normalized : null; +} + +function splitSummaryIntoSentences(summary: string): string[] { + return summary + .split(/\n+|(?<=[.!?。])\s+/) + .map(normalizeSentence) + .filter((value): value is string => Boolean(value)); +} + +function classifyMemorySentence(content: string): SelectedCompactMemory | null { + if (!content) return null; + + const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content)); + if (matchedPatterns.length === 0) return null; + + const primary = matchedPatterns[0]; + const keywords = [ + ...new Set( + matchedPatterns + .map((pattern) => pattern.keyword) + .filter(Boolean), + ), + ]; + + return { + content, + keywords, + memoryKind: primary?.kind ?? null, + }; +} + +export function selectCompactMemoriesFromSummary( + summary: string, + roomKey: string, +): SelectedCompactMemory[] { + if (!summary.trim()) return []; + + const selected = splitSummaryIntoSentences(summary) + .map((sentence) => classifyMemorySentence(sentence)) + .filter((value): value is SelectedCompactMemory => Boolean(value)) + .slice(0, MAX_SELECTED_MEMORIES) + .map((entry) => ({ + ...entry, + keywords: [...new Set([roomKey, ...entry.keywords])], + })); + + return selected; +} diff --git a/runners/agent-runner/test/memory-selection.test.ts b/runners/agent-runner/test/memory-selection.test.ts new file mode 100644 index 0000000..154068b --- /dev/null +++ b/runners/agent-runner/test/memory-selection.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { selectCompactMemoriesFromSummary } from '../src/memory-selection.js'; + +describe('memory selection', () => { + it('keeps stable room memories and drops transient status lines', () => { + const selected = selectCompactMemoriesFromSummary( + [ + '방 메모리는 새 세션 시작 시에만 주입한다.', + '테스트 599개 통과.', + '사용자는 수동 명령보다 자동 기억 형성을 원한다.', + ].join(' '), + 'room:ejclaw', + ); + + expect(selected).toHaveLength(2); + expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.'); + expect(selected[1].memoryKind).toBe('preference'); + expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true); + }); + + it('returns no memories for purely operational summaries', () => { + const selected = selectCompactMemoriesFromSummary( + '메멘토 대체 v1 로컬 구현은 끝냈습니다. 테스트 599개 통과.', + 'room:ejclaw', + ); + + expect(selected).toEqual([]); + }); + + it('keeps stable rule sentences even when they contain operational words', () => { + const selected = selectCompactMemoriesFromSummary( + '배포 전에 항상 테스트를 돌리는 것이 원칙이다.', + 'room:ejclaw', + ); + + expect(selected).toHaveLength(1); + expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.'); + expect(selected[0].memoryKind).toBe('room_norm'); + }); +}); diff --git a/src/db.test.ts b/src/db.test.ts index a0c880e..4e01adc 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _setMemoryTimestampsForTests, _setRegisteredGroupForTests, assignRoom, claimServiceHandoff, @@ -1342,6 +1343,66 @@ describe('memories', () => { expect(recalled.some((memory) => memory.content === 'memory-0')).toBe(false); expect(recalled.some((memory) => memory.content === 'memory-304')).toBe(true); }); + + it('archives stale compact memories before recall using last_used_at TTL', () => { + const staleId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '오래된 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:stale', + }); + rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl', + content: '최근에 다시 쓰인 compact memory', + keywords: ['room:ttl'], + sourceKind: 'compact', + sourceRef: 'compact:fresh', + }); + + _setMemoryTimestampsForTests(staleId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl', + limit: 10, + }); + + expect(recalled.some((memory) => memory.content === '오래된 compact memory')).toBe(false); + expect( + recalled.some((memory) => memory.content === '최근에 다시 쓰인 compact memory'), + ).toBe(true); + }); + + it('keeps explicit memories even when they are old', () => { + const explicitId = rememberMemory({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + content: '관리자가 남긴 고정 규칙', + keywords: ['room:ttl-explicit'], + sourceKind: 'explicit', + sourceRef: 'msg:1', + }); + + _setMemoryTimestampsForTests(explicitId, { + createdAt: '2020-01-01T00:00:00.000Z', + lastUsedAt: '2020-01-02T00:00:00.000Z', + }); + + const recalled = recallMemories({ + scopeKind: 'room', + scopeKey: 'room:ttl-explicit', + limit: 10, + }); + + expect(recalled).toHaveLength(1); + expect(recalled[0].content).toBe('관리자가 남긴 고정 규칙'); + }); }); describe('work items', () => { diff --git a/src/db.ts b/src/db.ts index 3a172e8..644a3df 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1017,12 +1017,49 @@ export function _setStoredRoomOwnerAgentTypeForTests( ).run(ownerAgentType, new Date().toISOString(), chatJid); } +/** @internal - for tests only. */ +export function _setMemoryTimestampsForTests( + id: number, + args: { + createdAt?: string; + lastUsedAt?: string | null; + archivedAt?: string | null; + }, +): void { + const existing = db + .prepare( + `SELECT created_at, last_used_at, archived_at + FROM memories + WHERE id = ?`, + ) + .get(id) as + | { + created_at: string; + last_used_at: string | null; + archived_at: string | null; + } + | undefined; + if (!existing) throw new Error(`Memory ${id} not found`); + + db.prepare( + `UPDATE memories + SET created_at = ?, last_used_at = ?, archived_at = ? + WHERE id = ?`, + ).run( + args.createdAt ?? existing.created_at, + args.lastUsedAt === undefined ? existing.last_used_at : args.lastUsedAt, + args.archivedAt === undefined ? existing.archived_at : args.archivedAt, + id, + ); +} + const MEMORY_SCOPE_LIMITS: Record = { room: 300, user: 100, project: 200, global: 100, }; +const COMPACT_MEMORY_TTL_DAYS = 30; interface MemoryDatabaseRow { id: number; @@ -1155,6 +1192,39 @@ export function archiveMemory(id: number): void { ).run(new Date().toISOString(), id); } +function buildCompactMemoryExpiryCutoff(nowIso: string): string { + return new Date( + new Date(nowIso).getTime() - COMPACT_MEMORY_TTL_DAYS * 24 * 60 * 60 * 1000, + ).toISOString(); +} + +export function expireStaleMemories(args?: { + scopeKind?: MemoryScopeKind; + scopeKey?: string; + now?: string; +}): number { + const nowIso = args?.now ?? new Date().toISOString(); + const cutoff = buildCompactMemoryExpiryCutoff(nowIso); + const scopeClause = + args?.scopeKind && args?.scopeKey + ? 'AND scope_kind = ? AND scope_key = ?' + : ''; + const stmt = db.prepare( + `UPDATE memories + SET archived_at = COALESCE(archived_at, ?) + WHERE archived_at IS NULL + AND source_kind = 'compact' + AND COALESCE(last_used_at, created_at) < ? + ${scopeClause}`, + ); + const result = ( + args?.scopeKind && args?.scopeKey + ? stmt.run(nowIso, cutoff, args.scopeKind, args.scopeKey) + : stmt.run(nowIso, cutoff) + ) as { changes?: number }; + return result.changes ?? 0; +} + export function enforceMemoryBounds( scopeKind: MemoryScopeKind, scopeKey: string, @@ -1221,12 +1291,20 @@ export function rememberMemory(input: { createdAt, ); const row = db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }; + expireStaleMemories({ + scopeKind: input.scopeKind, + scopeKey: input.scopeKey, + }); enforceMemoryBounds(input.scopeKind, input.scopeKey); return row.id; } export function recallMemories(query: RecallMemoryQuery): MemoryRecord[] { const limit = Math.max(1, query.limit ?? 6); + expireStaleMemories({ + scopeKind: query.scopeKind, + scopeKey: query.scopeKey, + }); const rows = getMemoryRowsForScope(query.scopeKind, query.scopeKey); if (rows.length === 0) return [];