/** * Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. * * 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지난 에피소드는 * "지금 이 순간 관련 가능성" 보다 "역사적 패턴" 가치가 커서, 디테일을 압축해 * Long-Term 으로 옮기고 원본은 archive 하는 게 효율적. * * v1 설계 (LLM-less, 예측 가능): * - LLM 호출 없이 기존 EpisodicEntry 의 title/summary/keyDecisions/topics 를 * 구조적으로 결합해 LongTerm 'episode-digest' content 생성 * - 장점: 비용 0, 결정적·재현 가능, LM Studio 다운 시에도 동작 * - 단점: LLM 요약보다 농축도 낮음 — 추후 strict 모드에서 LLM 패스 추가 가능 * * 원본 episode 처리: 두 가지 옵션 — 사용자 설정으로 결정. * - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존. 검색에서 제외되나 * 히스토리·디버깅용으로 디스크에 남음. * - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동. * 디스크 정리에 더 깔끔하나 복구 시 수동. */ import * as fs from 'fs'; import * as path from 'path'; import { EpisodicMemory } from './EpisodicMemory'; import { LongTermMemory } from './LongTermMemory'; import { EpisodicEntry } from './types'; export type DistillationArchiveMode = 'mark-promoted' | 'archive-file'; export interface DistillationOptions { /** 며칠 이상 지난 episode 를 대상으로. 기본 30. */ ageThresholdDays: number; /** Archive 처리 방식. 기본 'mark-promoted'. */ archiveMode: DistillationArchiveMode; /** 한 번에 처리할 최대 episode 수 (안전장치). 기본 50. */ maxBatchSize: number; } export const DEFAULT_DISTILLATION_OPTIONS: DistillationOptions = { ageThresholdDays: 30, archiveMode: 'mark-promoted', maxBatchSize: 50, }; export interface DistillationReport { candidateCount: number; promotedCount: number; archivedCount: number; longTermDigestIds: string[]; skipped: { episodeId: string; reason: string }[]; durationMs: number; } /** * Episode → LongTerm 'episode-digest' content 변환. 결정적·LLM 없음. */ function episodeToDigestContent(ep: EpisodicEntry): string { const date = new Date(ep.timestamp).toISOString().slice(0, 10); const parts: string[] = []; parts.push(`[${date}] ${ep.title}`); if (ep.summary && ep.summary.trim()) parts.push(`요약: ${ep.summary.trim()}`); if (ep.keyDecisions && ep.keyDecisions.length > 0) { parts.push(`결정: ${ep.keyDecisions.slice(0, 5).join(' · ')}`); } if (ep.topics && ep.topics.length > 0) { parts.push(`토픽: ${ep.topics.slice(0, 8).join(', ')}`); } return parts.join('\n'); } /** * Distillation 실행 — stale episodes 를 LongTerm digest 로 승급 + archive. * * 호출자: `/memory distill` 슬래시 명령 + 세션 종료 시 auto-trigger (선택). */ export function distillStaleEpisodes( episodicMemory: EpisodicMemory, longTermMemory: LongTermMemory, brainPath: string, options: Partial = {}, ): DistillationReport { const opts: DistillationOptions = { ...DEFAULT_DISTILLATION_OPTIONS, ...options }; const start = Date.now(); const report: DistillationReport = { candidateCount: 0, promotedCount: 0, archivedCount: 0, longTermDigestIds: [], skipped: [], durationMs: 0, }; const candidates = episodicMemory.findStaleEpisodes(opts.ageThresholdDays).slice(0, opts.maxBatchSize); report.candidateCount = candidates.length; if (candidates.length === 0) { report.durationMs = Date.now() - start; return report; } const archiveDir = path.join(brainPath, 'memory', 'episodes', 'archive'); if (opts.archiveMode === 'archive-file') { try { fs.mkdirSync(archiveDir, { recursive: true }); } catch { /* ignore */ } } for (const ep of candidates) { try { // 1. LongTerm digest entry 생성. confidence 약간 낮춤 (압축 손실 반영). const digestContent = episodeToDigestContent(ep); const digest = longTermMemory.addEntry( 'episode-digest', digestContent, `episodic:${ep.id}`, 0.7, ); report.longTermDigestIds.push(digest.id); // 2. 원본 episode 처리. const marked = episodicMemory.markPromoted(ep.id, digest.id); if (!marked) { report.skipped.push({ episodeId: ep.id, reason: 'markPromoted failed (file not found)' }); continue; } if (opts.archiveMode === 'archive-file') { // 파일 위치 찾아서 archive 디렉터리로 이동. const moved = tryMoveEpisodeFileToArchive(ep.id, path.join(brainPath, 'memory', 'episodes'), archiveDir); if (moved) report.archivedCount++; } report.promotedCount++; } catch (e: any) { report.skipped.push({ episodeId: ep.id, reason: e?.message || String(e) }); } } report.durationMs = Date.now() - start; return report; } function tryMoveEpisodeFileToArchive(episodeId: string, episodeDir: string, archiveDir: string): boolean { try { const files = fs.readdirSync(episodeDir).filter((f) => f.endsWith('.json')); for (const file of files) { const full = path.join(episodeDir, file); try { const raw = fs.readFileSync(full, 'utf-8'); const parsed = JSON.parse(raw) as EpisodicEntry; if (parsed.id === episodeId) { fs.renameSync(full, path.join(archiveDir, file)); return true; } } catch { /* skip */ } } } catch { /* ignore */ } return false; } /** * Distillation 마지막 실행 시각을 저장·조회 — 자동 트리거가 *너무 자주* 안 돌도록. * brainPath 의 marker 파일 사용 (vscode.globalState 안 쓰는 이유: 메모리 인프라가 * BrainProfile-scoped 라 brain 디렉터리에 두는 게 일관성 있음). */ const MARKER_FILE = 'distillation_last_run.json'; export interface DistillationMarker { timestamp: number; report?: Partial; } export function getLastDistillationRun(brainPath: string): DistillationMarker | null { try { const fp = path.join(brainPath, 'memory', MARKER_FILE); if (!fs.existsSync(fp)) return null; return JSON.parse(fs.readFileSync(fp, 'utf-8')) as DistillationMarker; } catch { return null; } } export function recordDistillationRun(brainPath: string, report: DistillationReport): void { try { const dir = path.join(brainPath, 'memory'); fs.mkdirSync(dir, { recursive: true }); const marker: DistillationMarker = { timestamp: Date.now(), report: { candidateCount: report.candidateCount, promotedCount: report.promotedCount, archivedCount: report.archivedCount, durationMs: report.durationMs, }, }; fs.writeFileSync(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2), 'utf-8'); } catch { /* ignore */ } } /** 자동 트리거 게이트 — 마지막 실행 후 N일 경과 시 true. */ export function shouldAutoDistill(brainPath: string, intervalDays: number): boolean { const last = getLastDistillationRun(brainPath); if (!last) return true; const elapsed = (Date.now() - last.timestamp) / (1000 * 60 * 60 * 24); return elapsed >= intervalDays; }