feat: integrate unified RAG pipeline and bump version to 2.60.0

This commit is contained in:
g1nation
2026-05-04 11:00:01 +09:00
parent 0515dd625d
commit 445d530b63
16 changed files with 2178 additions and 112 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* ============================================================
* Episodic Memory (일화 기억)
*
* 과거 대화/회의/결정의 맥락 흐름을 저장합니다.
* 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다.
* "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록.
* 저장 위치: {brainPath}/memory/episodes/*.json
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { EpisodicEntry, MemoryContextResult } from './types';
export class EpisodicMemory {
private episodeDir: string;
private maxEpisodes: number;
constructor(brainPath: string, maxEpisodes = 50) {
this.episodeDir = path.join(brainPath, 'memory', 'episodes');
this.maxEpisodes = maxEpisodes;
if (!fs.existsSync(this.episodeDir)) {
fs.mkdirSync(this.episodeDir, { recursive: true });
}
}
// ─── Episode Creation ───
/**
* 대화 히스토리에서 에피소드를 생성하고 저장합니다.
* LLM 호출 없이 패턴 기반으로 요약합니다.
*/
public createEpisode(
sessionId: string,
messages: Array<{ role: string; content: string; timestamp?: number }>,
projectContext?: string
): EpisodicEntry | null {
// 너무 짧은 대화는 에피소드로 저장하지 않음
const userMessages = messages.filter((m) => m.role === 'user');
if (userMessages.length < 2) return null;
const title = this.generateTitle(userMessages);
const summary = this.generateSummary(messages);
const keyDecisions = this.extractDecisions(messages);
const topics = this.extractTopics(messages);
const firstTimestamp = messages[0]?.timestamp || Date.now();
const lastTimestamp = messages[messages.length - 1]?.timestamp || Date.now();
const episode: EpisodicEntry = {
id: crypto.randomUUID(),
sessionId,
title,
summary,
keyDecisions,
topics,
projectContext,
timestamp: firstTimestamp,
duration: lastTimestamp - firstTimestamp,
messageCount: messages.length
};
this.saveEpisode(episode);
this.pruneOldEpisodes();
return episode;
}
private saveEpisode(episode: EpisodicEntry): void {
try {
const date = new Date(episode.timestamp).toISOString().slice(0, 10);
const slug = episode.title
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, '_')
.slice(0, 40);
const fileName = `ep_${date}_${slug}.json`;
const filePath = path.join(this.episodeDir, fileName);
fs.writeFileSync(filePath, JSON.stringify(episode, null, 2), 'utf-8');
} catch { /* silently fail */ }
}
// ─── Episode Retrieval ───
/**
* 저장된 모든 에피소드를 최신순으로 로드합니다.
*/
public loadAllEpisodes(): EpisodicEntry[] {
try {
const files = fs.readdirSync(this.episodeDir)
.filter((f) => f.endsWith('.json'))
.sort()
.reverse();
const episodes: EpisodicEntry[] = [];
for (const file of files) {
try {
const raw = fs.readFileSync(path.join(this.episodeDir, file), 'utf-8');
episodes.push(JSON.parse(raw) as EpisodicEntry);
} catch { /* skip corrupted */ }
}
return episodes;
} catch {
return [];
}
}
/**
* 프롬프트와 관련된 에피소드를 검색합니다.
*/
public findRelevantEpisodes(prompt: string, limit = 3): EpisodicEntry[] {
const episodes = this.loadAllEpisodes();
const promptLower = prompt.toLowerCase();
const terms = promptLower
.split(/[^a-z0-9가-힣_]+/g)
.filter((t) => t.length >= 2)
.slice(0, 20);
const scored = episodes.map((ep) => {
let score = 0;
const searchText = [ep.title, ep.summary, ...ep.keyDecisions, ...ep.topics]
.join(' ')
.toLowerCase();
for (const term of terms) {
if (searchText.includes(term)) score += 1;
}
// Topic match gets extra weight
for (const topic of ep.topics) {
if (promptLower.includes(topic.toLowerCase())) score += 3;
}
// Recency boost
const daysAgo = (Date.now() - ep.timestamp) / (1000 * 60 * 60 * 24);
if (daysAgo < 3) score += 2;
else if (daysAgo < 7) score += 1;
return { episode: ep, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((s) => s.episode);
}
// ─── Context Building ───
public buildContext(currentPrompt: string, limit = 3): MemoryContextResult | null {
const relevant = this.findRelevantEpisodes(currentPrompt, limit);
if (relevant.length === 0) return null;
const content = relevant
.map((ep) => {
const date = new Date(ep.timestamp).toISOString().slice(0, 10);
const decisions = ep.keyDecisions.length > 0
? `\n Decisions: ${ep.keyDecisions.join('; ')}`
: '';
return `- [${date}] ${ep.title}: ${ep.summary}${decisions}`;
})
.join('\n');
return {
layer: 'episodic',
label: 'Episodic Memory (과거 대화 / 결정 흐름)',
content,
relevance: 0.7
};
}
// ─── Internal Helpers ───
private generateTitle(userMessages: Array<{ content: string }>): string {
// 첫 번째 사용자 메시지에서 제목 생성
const first = userMessages[0]?.content || '';
const cleaned = first
.replace(/```[\s\S]*?```/g, '') // 코드 블록 제거
.replace(/\n+/g, ' ')
.trim();
if (cleaned.length <= 60) return cleaned || 'Untitled Session';
return cleaned.slice(0, 57) + '...';
}
private generateSummary(
messages: Array<{ role: string; content: string }>
): string {
// 사용자 메시지의 핵심 키워드 기반 요약
const userMessages = messages
.filter((m) => m.role === 'user')
.map((m) => m.content.replace(/```[\s\S]*?```/g, '').trim());
const allText = userMessages.join(' ');
if (allText.length <= 200) return allText;
// Take first and last user messages for summary
const firstMsg = userMessages[0]?.slice(0, 100) || '';
const lastMsg = userMessages[userMessages.length - 1]?.slice(0, 100) || '';
return `시작: ${firstMsg} → 최종: ${lastMsg}`;
}
private extractDecisions(
messages: Array<{ role: string; content: string }>
): string[] {
const decisions: string[] = [];
const patterns = [
/(?:결정|decided|결론|확정)[\s:]+(.{10,120})/gi,
/(?:으로\s*(?:하자|가자|결정|확정))[.!]?\s*(.{0,80})/g,
/(?:let's go with|we'll use|confirmed|선택)\s+(.{5,80})/gi
];
for (const msg of messages) {
for (const pattern of patterns) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(msg.content)) !== null) {
const decision = (match[1] || match[0]).trim();
if (decision.length > 5 && !decisions.includes(decision)) {
decisions.push(decision);
}
}
}
}
return decisions.slice(0, 5);
}
private extractTopics(
messages: Array<{ role: string; content: string }>
): string[] {
const allText = messages
.filter((m) => m.role === 'user')
.map((m) => m.content)
.join(' ')
.toLowerCase();
// Extract frequent meaningful terms
const words = allText
.split(/[^a-z0-9가-힣_]+/g)
.filter((w) => w.length >= 3);
const freq = new Map<string, number>();
for (const word of words) {
freq.set(word, (freq.get(word) || 0) + 1);
}
return Array.from(freq.entries())
.filter(([, count]) => count >= 2)
.sort(([, a], [, b]) => b - a)
.slice(0, 8)
.map(([word]) => word);
}
private pruneOldEpisodes(): void {
try {
const files = fs.readdirSync(this.episodeDir)
.filter((f) => f.endsWith('.json'))
.sort()
.reverse();
// Delete episodes beyond the max limit
if (files.length > this.maxEpisodes) {
const toDelete = files.slice(this.maxEpisodes);
for (const file of toDelete) {
try {
fs.unlinkSync(path.join(this.episodeDir, file));
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
}
+243
View File
@@ -0,0 +1,243 @@
/**
* ============================================================
* 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;
constructor(brainPath: string) {
const memoryDir = path.join(brainPath, 'memory');
if (!fs.existsSync(memoryDir)) {
fs.mkdirSync(memoryDir, { recursive: true });
}
this.filePath = path.join(memoryDir, 'long_term.json');
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): LongTermEntry {
const entry: LongTermEntry = {
id: crypto.randomUUID(),
category,
content: content.trim(),
source,
confidence,
createdAt: Date.now(),
lastReferencedAt: Date.now(),
referenceCount: 0
};
this.store.entries.push(entry);
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;
}
public getAllEntries(): LongTermEntry[] {
return [...this.store.entries];
}
public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] {
return this.store.entries.filter((e) => e.category === category);
}
// ─── Context Building ───
/**
* 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다.
*/
public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null {
if (this.store.entries.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 = this.store.entries.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 = this.store.entries
.filter((e) => e.category === 'rule' || e.category === 'goal')
.slice(0, 5);
if (alwaysInclude.length === 0) return null;
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
for (const { entry } of relevant) {
entry.lastReferencedAt = Date.now();
entry.referenceCount++;
}
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 ───
/**
* 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다.
* 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;
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
const seen = new Set<string>();
return candidates.filter((c) => {
const key = c.content.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
}
+115
View File
@@ -0,0 +1,115 @@
/**
* ============================================================
* Memory Extractor (기억 추출기)
*
* 대화 종료 시 히스토리를 분석하여 각 메모리 레이어에
* 저장할 정보를 자동으로 추출합니다.
* LLM 호출 없이 패턴 매칭 기반으로 동작합니다.
* ============================================================
*/
import { LongTermMemory } from './LongTermMemory';
import { ProjectMemory } from './ProjectMemory';
import { EpisodicMemory } from './EpisodicMemory';
interface ExtractionResult {
longTermCandidates: number;
episodeCreated: boolean;
projectUpdated: boolean;
}
export class MemoryExtractor {
/**
* 세션 종료 시 모든 메모리 레이어에 대해 추출을 수행합니다.
*/
public extractFromSession(
sessionId: string,
messages: Array<{ role: string; content: string; timestamp?: number }>,
longTermMemory: LongTermMemory,
episodicMemory: EpisodicMemory,
projectMemory: ProjectMemory | null,
projectContext?: string
): ExtractionResult {
const result: ExtractionResult = {
longTermCandidates: 0,
episodeCreated: false,
projectUpdated: false
};
// 1. Long-Term Memory 추출
const candidates = LongTermMemory.extractCandidates(messages);
for (const candidate of candidates) {
longTermMemory.addEntry(
candidate.category,
candidate.content,
`session:${sessionId}`,
0.7 // 자동 추출이므로 기본 신뢰도 0.7
);
}
result.longTermCandidates = candidates.length;
// 2. Episodic Memory 생성
const episode = episodicMemory.createEpisode(
sessionId,
messages,
projectContext
);
result.episodeCreated = !!episode;
// 3. Project Memory 업데이트 (프로젝트 관련 대화인 경우)
if (projectMemory && projectContext) {
const updated = this.extractProjectInfo(messages, projectMemory);
result.projectUpdated = updated;
}
return result;
}
/**
* 대화에서 프로젝트 관련 정보를 추출하여 Project Memory에 저장합니다.
*/
private extractProjectInfo(
messages: Array<{ role: string; content: string }>,
projectMemory: ProjectMemory
): boolean {
let updated = false;
const allText = messages.map((m) => m.content).join('\n');
// Tech stack 추출
const techPatterns = [
/(?:사용|using|사용하는|tech\s*stack|기술\s*스택)[\s:]*([^\n]+)/gi
];
for (const pattern of techPatterns) {
let match;
while ((match = pattern.exec(allText)) !== null) {
const techs = match[1]
.split(/[,\s]+/)
.filter((t) => t.length >= 2 && t.length <= 20);
for (const tech of techs) {
projectMemory.addTechStack(tech.trim());
updated = true;
}
}
}
// Bug report 추출
const bugPatterns = [
/(?:버그|bug|오류|error|이슈|issue)[\s:]+(.{10,200})/gi
];
for (const pattern of bugPatterns) {
let match;
while ((match = pattern.exec(allText)) !== null) {
// 간단한 버그만 자동 기록 (상세 분석은 사용자 확인 필요)
// 여기서는 패턴만 감지하고, 실제 기록은 사용자 확인 후
updated = true;
}
}
if (updated) {
projectMemory.save();
}
return updated;
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* ============================================================
* Procedural Memory (절차 기억)
*
* 반복 작업의 절차와 패턴을 관리합니다.
* 기존 skill.md 시스템과 통합되어, Brain의 memory/procedures/ 아래의
* MD 파일을 스캔하여 절차를 로드합니다.
* 저장 위치: {brainPath}/memory/procedures/*.md
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import { ProceduralEntry, MemoryContextResult } from './types';
export class ProceduralMemory {
private procedures: ProceduralEntry[] = [];
private procedureDir: string;
private loaded = false;
constructor(brainPath: string) {
this.procedureDir = path.join(brainPath, 'memory', 'procedures');
if (!fs.existsSync(this.procedureDir)) {
fs.mkdirSync(this.procedureDir, { recursive: true });
}
}
// ─── Loading ───
/**
* procedures/ 디렉토리에서 MD 파일을 스캔하여 절차를 로드합니다.
*
* MD 파일 형식:
* ```
* ---
* name: P-Reinforce Wikification
* triggers: ["wiki화", "위키", "wikify"]
* ---
* 1. 첫 번째 단계
* 2. 두 번째 단계
* ```
*/
public loadProcedures(): ProceduralEntry[] {
if (this.loaded) return this.procedures;
try {
if (!fs.existsSync(this.procedureDir)) {
this.loaded = true;
return this.procedures;
}
const files = fs.readdirSync(this.procedureDir)
.filter((f) => f.endsWith('.md'));
for (const file of files) {
const filePath = path.join(this.procedureDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const entry = this.parseProcedureFile(content, filePath);
if (entry) {
this.procedures.push(entry);
}
} catch { /* skip unreadable files */ }
}
this.loaded = true;
} catch { /* directory not accessible */ }
return this.procedures;
}
private parseProcedureFile(content: string, filePath: string): ProceduralEntry | null {
// Parse YAML frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
let name = path.basename(filePath, '.md').replace(/_/g, ' ');
let triggers: string[] = [];
if (frontmatterMatch) {
const yaml = frontmatterMatch[1];
const nameMatch = yaml.match(/name:\s*(.+)/);
if (nameMatch) name = nameMatch[1].trim();
const triggerMatch = yaml.match(/triggers:\s*\[([^\]]+)\]/);
if (triggerMatch) {
triggers = triggerMatch[1]
.split(',')
.map((t) => t.trim().replace(/['"]/g, ''))
.filter(Boolean);
}
}
// Extract steps (numbered lines or bullet points)
const body = frontmatterMatch
? content.slice(frontmatterMatch[0].length).trim()
: content.trim();
const steps = body
.split('\n')
.filter((line) => /^\s*(?:\d+\.|[-*])\s+/.test(line))
.map((line) => line.replace(/^\s*(?:\d+\.|[-*])\s+/, '').trim());
if (!name && steps.length === 0) return null;
// Auto-generate triggers from name if not specified
if (triggers.length === 0) {
triggers = name
.toLowerCase()
.split(/[\s_-]+/)
.filter((t) => t.length >= 2);
}
return {
id: path.basename(filePath, '.md'),
name,
triggerPatterns: triggers,
steps: steps.length > 0 ? steps : [body.slice(0, 500)],
filePath,
lastUsed: 0,
useCount: 0
};
}
// ─── Matching ───
/**
* 프롬프트에 매칭되는 절차를 찾아 반환합니다.
*/
public findMatchingProcedures(prompt: string): ProceduralEntry[] {
this.loadProcedures();
const promptLower = prompt.toLowerCase();
return this.procedures.filter((proc) =>
proc.triggerPatterns.some((trigger) => promptLower.includes(trigger.toLowerCase()))
);
}
// ─── Context Building ───
public buildContext(currentPrompt: string): MemoryContextResult | null {
const matches = this.findMatchingProcedures(currentPrompt);
if (matches.length === 0) return null;
// Mark as used
for (const proc of matches) {
proc.lastUsed = Date.now();
proc.useCount++;
}
const content = matches
.map((proc) => {
const stepsText = proc.steps.length > 0
? proc.steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')
: ' (절차 상세 없음)';
return `📋 ${proc.name}\n${stepsText}`;
})
.join('\n\n');
return {
layer: 'procedural',
label: 'Procedural Memory (반복 작업 절차)',
content,
relevance: 0.9
};
}
// ─── Utility ───
public getAllProcedures(): ProceduralEntry[] {
this.loadProcedures();
return [...this.procedures];
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* ============================================================
* Project Memory (프로젝트 기억)
*
* 프로젝트별 요구사항, 코드 구조, 아키텍처 결정, 버그 기록 등을
* 프로젝트 로컬에 저장하고 관리합니다.
* 저장 위치: {projectRoot}/.astra/project_memory.json
* ============================================================
*/
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { ProjectMemoryStore, ArchitectureDecision, BugRecord, MemoryContextResult } from './types';
export class ProjectMemory {
private store: ProjectMemoryStore;
private filePath: string;
private dirty = false;
constructor(projectRoot: string) {
const astraDir = path.join(projectRoot, '.astra');
if (!fs.existsSync(astraDir)) {
fs.mkdirSync(astraDir, { recursive: true });
}
this.filePath = path.join(astraDir, 'project_memory.json');
this.store = this.load(projectRoot);
}
// ─── Persistence ───
private load(projectRoot: string): ProjectMemoryStore {
try {
if (fs.existsSync(this.filePath)) {
const raw = fs.readFileSync(this.filePath, 'utf-8');
return JSON.parse(raw) as ProjectMemoryStore;
}
} catch { /* start fresh */ }
return {
version: 1,
projectId: this.hashPath(projectRoot),
projectName: path.basename(projectRoot),
techStack: [],
architectureDecisions: [],
bugRecords: [],
requirements: [],
designDirection: '',
codeConventions: [],
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 */ }
}
private hashPath(p: string): string {
return crypto.createHash('sha256').update(p).digest('hex').slice(0, 12);
}
// ─── Getters ───
public getStore(): ProjectMemoryStore {
return { ...this.store };
}
// ─── Tech Stack ───
public setTechStack(stack: string[]): void {
this.store.techStack = [...new Set(stack)];
this.dirty = true;
this.save();
}
public addTechStack(tech: string): void {
if (!this.store.techStack.includes(tech)) {
this.store.techStack.push(tech);
this.dirty = true;
this.save();
}
}
// ─── Architecture Decisions ───
public addArchitectureDecision(
title: string,
decision: string,
rationale: string,
alternatives: string[] = []
): ArchitectureDecision {
const entry: ArchitectureDecision = {
id: crypto.randomUUID(),
title,
decision,
rationale,
alternatives,
date: Date.now()
};
this.store.architectureDecisions.push(entry);
this.dirty = true;
this.save();
return entry;
}
// ─── Bug Records ───
public addBugRecord(
description: string,
rootCause: string,
fix: string,
relatedFiles: string[] = []
): BugRecord {
const entry: BugRecord = {
id: crypto.randomUUID(),
description,
rootCause,
fix,
date: Date.now(),
relatedFiles
};
this.store.bugRecords.push(entry);
this.dirty = true;
this.save();
return entry;
}
// ─── Requirements ───
public addRequirement(req: string): void {
if (!this.store.requirements.includes(req)) {
this.store.requirements.push(req);
this.dirty = true;
this.save();
}
}
// ─── Design Direction ───
public setDesignDirection(direction: string): void {
this.store.designDirection = direction;
this.dirty = true;
this.save();
}
// ─── Code Conventions ───
public addCodeConvention(convention: string): void {
if (!this.store.codeConventions.includes(convention)) {
this.store.codeConventions.push(convention);
this.dirty = true;
this.save();
}
}
// ─── Context Building ───
public buildContext(currentPrompt: string): MemoryContextResult | null {
const sections: string[] = [];
if (this.store.techStack.length > 0) {
sections.push(`Tech Stack: ${this.store.techStack.join(', ')}`);
}
if (this.store.designDirection) {
sections.push(`Design Direction: ${this.store.designDirection}`);
}
if (this.store.codeConventions.length > 0) {
sections.push(`Code Conventions:\n${this.store.codeConventions.map((c) => ` - ${c}`).join('\n')}`);
}
if (this.store.requirements.length > 0) {
const reqs = this.store.requirements.slice(-5);
sections.push(`Recent Requirements:\n${reqs.map((r) => ` - ${r}`).join('\n')}`);
}
// Show recent architecture decisions (last 3)
if (this.store.architectureDecisions.length > 0) {
const recent = this.store.architectureDecisions
.sort((a, b) => b.date - a.date)
.slice(0, 3);
sections.push(
`Architecture Decisions:\n${recent.map((d) => ` - ${d.title}: ${d.decision}`).join('\n')}`
);
}
// Show recent bugs (last 3)
if (this.store.bugRecords.length > 0) {
const recent = this.store.bugRecords
.sort((a, b) => b.date - a.date)
.slice(0, 3);
sections.push(
`Recent Bugs:\n${recent.map((b) => ` - ${b.description}${b.fix}`).join('\n')}`
);
}
if (sections.length === 0) return null;
return {
layer: 'project',
label: `Project Memory (${this.store.projectName})`,
content: sections.join('\n'),
relevance: 0.8
};
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* ============================================================
* Short-Term Memory (단기 기억)
*
* 현재 대화의 즉시 맥락을 관리합니다.
* FIFO 방식으로 최근 N개 메시지를 유지합니다.
* ============================================================
*/
import { ShortTermMessage, MemoryContextResult } from './types';
export class ShortTermMemory {
/**
* 가시적(사용자/어시스턴트) 메시지에서 Short-Term 맥락을 구성합니다.
*/
buildContext(
visibleHistory: Array<{ role: string; content: string }>,
limit: number,
summarize: (text: string, maxLen: number) => string
): MemoryContextResult | null {
if (limit <= 0 || visibleHistory.length === 0) return null;
const recent = visibleHistory
.slice(-limit)
.map((msg) => `- ${msg.role}: ${summarize(msg.content, 260)}`)
.join('\n');
if (!recent) return null;
return {
layer: 'short-term',
label: 'Short-Term Memory (현재 대화 흐름)',
content: recent,
relevance: 1.0
};
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* ============================================================
* MemoryManager — 5-Layer Cognitive Memory System (통합 진입점)
*
* Astra의 모든 메모리 레이어를 통합 관리하는 중앙 매니저입니다.
*
* ① Short-Term Memory — 현재 대화 흐름 (FIFO)
* ② Long-Term Memory — 사용자 취향/규칙/결정
* ③ Project Memory — 프로젝트별 지식
* ④ Procedural Memory — 반복 작업 절차 (skill.md)
* ⑤ Episodic Memory — 과거 대화/결정 흐름
* ============================================================
*/
import { BrainProfile } from '../config';
import { ShortTermMemory } from './ShortTermMemory';
import { LongTermMemory } from './LongTermMemory';
import { ProjectMemory } from './ProjectMemory';
import { ProceduralMemory } from './ProceduralMemory';
import { EpisodicMemory } from './EpisodicMemory';
import { MemoryExtractor } from './MemoryExtractor';
import { MemoryContextResult, MemoryConfig } from './types';
export { ShortTermMemory } from './ShortTermMemory';
export { LongTermMemory } from './LongTermMemory';
export { ProjectMemory } from './ProjectMemory';
export { ProceduralMemory } from './ProceduralMemory';
export { EpisodicMemory } from './EpisodicMemory';
export { MemoryExtractor } from './MemoryExtractor';
export * from './types';
export class MemoryManager {
private shortTerm: ShortTermMemory;
private longTerm: LongTermMemory;
private procedural: ProceduralMemory;
private episodic: EpisodicMemory;
private extractor: MemoryExtractor;
// Project Memory는 workspace별로 lazy-init
private projectMemories = new Map<string, ProjectMemory>();
private config: MemoryConfig;
constructor(brainPath: string, config?: Partial<MemoryConfig>) {
this.config = {
enabled: true,
shortTermLimit: 8,
longTermMaxEntries: 100,
episodicMaxEpisodes: 50,
projectMemoryEnabled: true,
proceduralMemoryEnabled: true,
episodicMemoryEnabled: true,
...config
};
this.shortTerm = new ShortTermMemory();
this.longTerm = new LongTermMemory(brainPath);
this.procedural = new ProceduralMemory(brainPath);
this.episodic = new EpisodicMemory(brainPath, this.config.episodicMaxEpisodes);
this.extractor = new MemoryExtractor();
}
// ─── Context Building (핵심 API) ───
/**
* 프롬프트에 대해 모든 메모리 레이어에서 관련 컨텍스트를 수집합니다.
* agent.ts의 buildMemoryContext()를 대체합니다.
*/
public buildContext(
currentPrompt: string,
visibleHistory: Array<{ role: string; content: string }>,
summarize: (text: string, maxLen: number) => string,
workspacePath?: string
): string {
if (!this.config.enabled) return '';
const layers: MemoryContextResult[] = [];
// ① Short-Term Memory
const stm = this.shortTerm.buildContext(
visibleHistory,
this.config.shortTermLimit,
summarize
);
if (stm) layers.push(stm);
// ② Long-Term Memory
const ltm = this.longTerm.buildContext(currentPrompt);
if (ltm) layers.push(ltm);
// ③ Project Memory
if (this.config.projectMemoryEnabled && workspacePath) {
const pm = this.getProjectMemory(workspacePath);
const pmCtx = pm.buildContext(currentPrompt);
if (pmCtx) layers.push(pmCtx);
}
// ④ Procedural Memory
if (this.config.proceduralMemoryEnabled) {
const proc = this.procedural.buildContext(currentPrompt);
if (proc) layers.push(proc);
}
// ⑤ Episodic Memory
if (this.config.episodicMemoryEnabled) {
const ep = this.episodic.buildContext(currentPrompt);
if (ep) layers.push(ep);
}
if (layers.length === 0) return '';
// 관련도 순으로 정렬
layers.sort((a, b) => b.relevance - a.relevance);
const sections = layers
.map((layer) => `### ${layer.label}\n${layer.content}`)
.join('\n\n');
return [
'',
'[MEMORY CONTEXT]',
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
sections
].join('\n');
}
// ─── Session Lifecycle ───
/**
* 세션 종료 시 호출하여 모든 메모리 레이어에 대해 추출을 수행합니다.
*/
public onSessionEnd(
sessionId: string,
messages: Array<{ role: string; content: string; timestamp?: number }>,
workspacePath?: string
): void {
if (!this.config.enabled) return;
const projectMemory = workspacePath
? this.getProjectMemory(workspacePath)
: null;
try {
this.extractor.extractFromSession(
sessionId,
messages,
this.longTerm,
this.episodic,
projectMemory,
workspacePath
);
} catch { /* memory extraction should never break the main flow */ }
// Persist long-term memory
this.longTerm.save();
}
// ─── Direct Access (for UI & advanced features) ───
public getLongTermMemory(): LongTermMemory {
return this.longTerm;
}
public getProceduralMemory(): ProceduralMemory {
return this.procedural;
}
public getEpisodicMemory(): EpisodicMemory {
return this.episodic;
}
public getProjectMemory(workspacePath: string): ProjectMemory {
if (!this.projectMemories.has(workspacePath)) {
this.projectMemories.set(workspacePath, new ProjectMemory(workspacePath));
}
return this.projectMemories.get(workspacePath)!;
}
// ─── Config ───
public updateConfig(partial: Partial<MemoryConfig>): void {
Object.assign(this.config, partial);
}
public getConfig(): MemoryConfig {
return { ...this.config };
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* ============================================================
* Memory Type Definitions (메모리 타입 정의)
*
* Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다.
* ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
* ============================================================
*/
// ─── Common ───
export type MemoryLayer = 'short-term' | 'long-term' | 'project' | 'procedural' | 'episodic';
export interface MemoryContextResult {
layer: MemoryLayer;
label: string;
content: string;
relevance: number; // 0.0 ~ 1.0
}
// ─── ① Short-Term Memory ───
export interface ShortTermMessage {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
// ─── ② Long-Term Memory ───
export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal';
export interface LongTermEntry {
id: string;
category: LongTermCategory;
content: string;
source: string; // 어떤 대화/세션에서 추출됐는지
confidence: number; // 0.0~1.0
createdAt: number;
lastReferencedAt: number;
referenceCount: number;
}
export interface LongTermStore {
version: number;
entries: LongTermEntry[];
lastUpdated: number;
}
// ─── ③ Project Memory ───
export interface ArchitectureDecision {
id: string;
title: string;
decision: string;
rationale: string;
alternatives: string[];
date: number;
}
export interface BugRecord {
id: string;
description: string;
rootCause: string;
fix: string;
date: number;
relatedFiles: string[];
}
export interface ProjectMemoryStore {
version: number;
projectId: string; // workspace 경로 기반 hash
projectName: string;
techStack: string[];
architectureDecisions: ArchitectureDecision[];
bugRecords: BugRecord[];
requirements: string[];
designDirection: string;
codeConventions: string[];
lastUpdated: number;
}
// ─── ④ Procedural Memory ───
export interface ProceduralEntry {
id: string;
name: string; // "P-Reinforce Wikification"
triggerPatterns: string[]; // ["wiki화", "위키", "wikify"]
steps: string[]; // 순서대로 실행할 절차
filePath: string; // 실제 MD 파일 경로
lastUsed: number;
useCount: number;
}
// ─── ⑤ Episodic Memory ───
export interface EpisodicEntry {
id: string;
sessionId: string;
title: string; // 자동 생성된 에피소드 제목
summary: string; // 대화 요약
keyDecisions: string[]; // 주요 결정사항
topics: string[]; // 주요 토픽 키워드
projectContext?: string; // 연관 프로젝트 경로
timestamp: number;
duration: number; // 세션 길이 (ms)
messageCount: number;
}
export interface EpisodicStore {
version: number;
episodes: EpisodicEntry[];
lastUpdated: number;
}
// ─── Memory Manager Config ───
export interface MemoryConfig {
enabled: boolean;
shortTermLimit: number;
longTermMaxEntries: number;
episodicMaxEpisodes: number;
projectMemoryEnabled: boolean;
proceduralMemoryEnabled: boolean;
episodicMemoryEnabled: boolean;
}