/** * ============================================================ * Long-Term Memory (장기 기억) * * 사용자의 취향, 프로젝트 목표, 반복 규칙, 과거 결정 사항을 * 영구적으로 저장하고 관리합니다. * 저장 위치: {brainPath}/memory/long_term.json * ============================================================ */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { LongTermEntry, LongTermStore, LongTermCategory, MemoryContextResult } from './types'; export class LongTermMemory { private store: LongTermStore; private filePath: string; private dirty = false; /** Hard cap on retained entries — oldest are trimmed when exceeded. Default 100 (matches MemoryConfig.longTermMaxEntries). */ private maxEntries: number; constructor(brainPath: string, maxEntries = 100) { const memoryDir = path.join(brainPath, 'memory'); if (!fs.existsSync(memoryDir)) { fs.mkdirSync(memoryDir, { recursive: true }); } this.filePath = path.join(memoryDir, 'long_term.json'); this.maxEntries = maxEntries > 0 ? maxEntries : 100; this.store = this.load(); } // ─── Persistence ─── private load(): LongTermStore { try { if (fs.existsSync(this.filePath)) { const raw = fs.readFileSync(this.filePath, 'utf-8'); return JSON.parse(raw) as LongTermStore; } } catch { /* start fresh */ } return { version: 1, entries: [], lastUpdated: Date.now() }; } public save(): void { if (!this.dirty) return; try { this.store.lastUpdated = Date.now(); fs.writeFileSync(this.filePath, JSON.stringify(this.store, null, 2), 'utf-8'); this.dirty = false; } catch { /* silently fail — memory is not critical path */ } } // ─── CRUD ─── public addEntry( category: LongTermCategory, content: string, source: string, confidence = 0.8, opts: { expiresAt?: number } = {}, ): LongTermEntry { const entry: LongTermEntry = { id: crypto.randomUUID(), category, content: content.trim(), source, confidence, createdAt: Date.now(), lastReferencedAt: Date.now(), referenceCount: 0, ...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}), }; this.store.entries.push(entry); // Enforce the retention cap — drop the oldest entries (by createdAt) once // over the limit. The store array is append-ordered, so the oldest are at // the front; we trim from there. if (this.store.entries.length > this.maxEntries) { this.store.entries.splice(0, this.store.entries.length - this.maxEntries); } this.dirty = true; this.save(); return entry; } public removeEntry(id: string): boolean { const before = this.store.entries.length; this.store.entries = this.store.entries.filter((e) => e.id !== id); if (this.store.entries.length < before) { this.dirty = true; this.save(); return true; } return false; } /** 만료된 entry 를 필터링한 활성 entries (검색·context build 가 보는 것). */ private getActiveEntries(): LongTermEntry[] { const now = Date.now(); return this.store.entries.filter((e) => !e.expiresAt || e.expiresAt > now); } public getAllEntries(opts: { includeExpired?: boolean } = {}): LongTermEntry[] { return opts.includeExpired ? [...this.store.entries] : this.getActiveEntries(); } public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] { return this.getActiveEntries().filter((e) => e.category === category); } /** * 기존 entry 의 expiresAt 갱신. id 또는 id prefix (8자 이상) 로 식별. * 반환: 갱신된 entry 또는 null (못 찾음). */ public setExpiration(idOrPrefix: string, expiresAt: number): LongTermEntry | null { const match = this.store.entries.find((e) => e.id === idOrPrefix) || (idOrPrefix.length >= 8 ? this.store.entries.find((e) => e.id.startsWith(idOrPrefix)) : undefined); if (!match) return null; match.expiresAt = expiresAt; this.dirty = true; this.save(); return match; } // ─── Context Building ─── /** * 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다. */ public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null { // 만료된 entry 는 검색에서 자동 제외 — Temporal Markers 의 핵심. const activeEntries = this.getActiveEntries(); if (activeEntries.length === 0) return null; const promptLower = currentPrompt.toLowerCase(); const terms = promptLower .split(/[^a-z0-9가-힣_]+/g) .filter((t) => t.length >= 2); // Score entries by relevance to prompt const scored = activeEntries.map((entry) => { let score = 0; const contentLower = entry.content.toLowerCase(); for (const term of terms) { if (contentLower.includes(term)) score += 2; } // Boost high-confidence and frequently referenced entries score += entry.confidence * 2; score += Math.min(entry.referenceCount * 0.5, 3); // Recency boost const daysSinceRef = (Date.now() - entry.lastReferencedAt) / (1000 * 60 * 60 * 24); if (daysSinceRef < 7) score += 1; return { entry, score }; }); const relevant = scored .filter((s) => s.score > 0) .sort((a, b) => b.score - a.score) .slice(0, maxEntries); if (relevant.length === 0) { // Still include all rules and goals even without prompt match — 만료 제외. const alwaysInclude = activeEntries .filter((e) => e.category === 'rule' || e.category === 'goal') .slice(0, 5); if (alwaysInclude.length === 0) return null; // 표시되는(=사용되는) 자동 추출 항목의 만료를 연장. const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS; for (const e of alwaysInclude) { if (e.expiresAt) { e.expiresAt = refreshAt; this.dirty = true; } } const content = alwaysInclude .map((e) => `- [${e.category}] ${e.content}`) .join('\n'); return { layer: 'long-term', label: 'Long-Term Memory (사용자 규칙 & 목표)', content, relevance: 0.5 }; } // Mark as referenced — 자동 추출(만료 있음) 항목은 참조 시 만료를 슬라이딩 연장해 // '쓰면 살아남고, 안 쓰면 TTL 뒤 소멸'. 영속(수동) 항목은 expiresAt 이 없어 무영향. const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS; for (const { entry } of relevant) { entry.lastReferencedAt = Date.now(); entry.referenceCount++; if (entry.expiresAt) entry.expiresAt = refreshAt; } this.dirty = true; const content = relevant .map(({ entry }) => `- [${entry.category}] ${entry.content}`) .join('\n'); return { layer: 'long-term', label: 'Long-Term Memory (사용자 취향 / 규칙 / 결정)', content, relevance: Math.min(relevant[0]?.score / 10 || 0.5, 1.0) }; } // ─── Extraction Helpers ─── /** 자동 추출 장기기억 기본 TTL (14일). 참조될 때마다 슬라이딩 연장된다. */ public static readonly AUTO_EXTRACT_TTL_MS = 14 * 24 * 60 * 60 * 1000; /** 짧은 후보 문자열에 박힌 구체적 에러 시그니처(예외명/에러코드/스택 조각) 탐지. */ private static readonly ERROR_NOISE = /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError|Exception|Traceback|ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET|errno|npm ERR!)\b|error\s+TS\d|:\d+:\d+\)|File ".+", line \d/i; /** * 붙여넣은 에러 로그·스택 트레이스·실패 출력처럼 보이는 텍스트인지 *보수적으로* 추정. * 이런 입력은 '분석 대상'(휘발)이지 '지식'(영속)이 아니므로 장기 기억 채굴에서 제외한다. * 일반 산문이 'error' 를 한 번 언급한 정도로는 걸리지 않게 강한/약한 신호를 구분한다. */ public static looksLikeErrorLog(text: string): boolean { if (!text) return false; const strong = [ /Traceback \(most recent call last\)/, /^\s*at\s+.+\(.+:\d+:\d+\)/m, // JS 스택 프레임 /\bFile ".+", line \d+/, // Python 프레임 /npm ERR!/, /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError)\b/, /\b(?:ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET)\b/, /error\s+TS\d{3,}/i, // tsc 에러 ]; if (strong.some((re) => re.test(text))) return true; const weak = (text.match(/\b(?:error|errors|exception|failed|failure|stacktrace|errno|fatal|panic|assertion)\b/gi) || []).length + (text.match(/\[(?:error|warn|fatal)\]/gi) || []).length; return weak >= 3 && text.split('\n').length >= 3; } /** * 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다. * LLM 호출 없이 동작합니다. */ public static extractCandidates( messages: Array<{ role: string; content: string }> ): Array<{ category: LongTermCategory; content: string }> { const candidates: Array<{ category: LongTermCategory; content: string }> = []; const rulePatterns = [ /(?:항상|언제나|무조건|반드시)\s+(.{5,80})/g, /(?:규칙|rule|원칙)[\s::]+(.{5,120})/gi, /(?:앞으로는?|이후에는?|다음부터는?)\s+(.{5,80})/g ]; const preferencePatterns = [ /(?:난|나는|저는|제가)\s+(.{5,60})\s*(?:좋아|선호|원해|싫어|안 ?좋아)/g, /(?:prefer|always use|don't use|never use)\s+(.{5,80})/gi ]; const goalPatterns = [ /(?:목표|goal|방향|direction)[\s::]+(.{5,120})/gi, /(?:최종\s*목표|궁극적으로|결국에는?)\s+(.{5,80})/g ]; const decisionPatterns = [ /(?:결정|decided|결론|conclusion)[\s::]+(.{5,120})/gi, /(?:으로\s*하자|으로\s*가자|으로\s*결정|으로\s*확정)/g ]; for (const msg of messages) { if (msg.role !== 'user') continue; const text = msg.content; // 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외. if (LongTermMemory.looksLikeErrorLog(text)) continue; for (const pattern of rulePatterns) { pattern.lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { candidates.push({ category: 'rule', content: match[0].trim() }); } } for (const pattern of preferencePatterns) { pattern.lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { candidates.push({ category: 'preference', content: match[0].trim() }); } } for (const pattern of goalPatterns) { pattern.lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { candidates.push({ category: 'goal', content: match[0].trim() }); } } for (const pattern of decisionPatterns) { pattern.lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { candidates.push({ category: 'decision', content: match[0].trim() }); } } } // Deduplicate by content + 에러 시그니처가 박힌 후보 제거 // ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지). const seen = new Set(); return candidates.filter((c) => { if (LongTermMemory.ERROR_NOISE.test(c.content)) return false; const key = c.content.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }); } }