Files
connectai/src/memory/LongTermMemory.ts
T
koriweb 6b017b0d31 feat: Bridge 타깃 토글 + /research 제거 + 환각·오염 방지 강화 (v2.2.205)
- Datacollect Bridge 로컬/NAS 타깃 토글(Settings 패널) + NAS URL/x-bridge-token.
  기본 local = 현행 동작 유지. (백엔드 NAS 분리 준비)
- /research(NotebookLM) 제거 — 로컬 Datacollect 앱 전용으로 분리.
- 에러로그 오염 차단: STT/스택트레이스/에러덤프를 장기기억 채굴 제외 + 자동
  추출 항목 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향.
- 컨텍스트 [주제] 태깅 + 교차오염 방지 경계 지침.
- "확인 불가" 사실 날조 금지 규칙(R7과 구분).
- /meet STT 오타 보정: 철자 정규화 허용하되 사실 날조는 차단.

타입체크 + 407 테스트 통과.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:47:55 +09:00

323 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ============================================================
* 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<string>();
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;
});
}
}