feat: add memory selection and TTL decay
This commit is contained in:
@@ -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: ${
|
||||
|
||||
73
runners/agent-runner/src/memory-selection.ts
Normal file
73
runners/agent-runner/src/memory-selection.ts
Normal 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;
|
||||
}
|
||||
43
runners/agent-runner/test/memory-selection.test.ts
Normal file
43
runners/agent-runner/test/memory-selection.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
78
src/db.ts
78
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<MemoryScopeKind, number> = {
|
||||
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 [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user