feat: add memory selection and TTL decay

This commit is contained in:
Eyejoker
2026-04-01 00:48:40 +09:00
parent c76126bd58
commit a955964522
5 changed files with 276 additions and 11 deletions

View File

@@ -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: ${

View File

@@ -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;
}

View File

@@ -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');
});
});