feat: integrate unified RAG pipeline and bump version to 2.60.0
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
# Patch Notes - v2.60.0 (2026-05-04)
|
||||
|
||||
## 🧠 Memory & Knowledge Search (Unified RAG Pipeline)
|
||||
- **Retrieval Orchestrator:** 통합 RAG 파이프라인(`src/retrieval`)이 전면 도입되었습니다. 무거운 외부 프레임워크(LlamaIndex 등) 없이 자체적으로 초경량 TF-IDF 스코어링 및 컨텍스트 예산 최적화를 수행합니다.
|
||||
- **2nd Brain (Obsidian) 연동:** 프로젝트 문서 및 옵시디언 지식 기지에서 질문과 연관된 핵심 문단을 자동 추출하여 AI 프롬프트에 주입합니다.
|
||||
- **다중 메모리 계층 동시 탐색:** 과거 대화(Episodic), 프로젝트 결정 사항(Project), 문제 해결 절차(Procedural) 등 다양한 메모리를 융합하여 입체적인 답변을 제공하도록 강화되었습니다.
|
||||
|
||||
---
|
||||
|
||||
# Patch Notes - v2.59.0 (2026-05-03)
|
||||
|
||||
## 🎨 UI/UX Polish & Stability
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "A local Jarvis-style project operating assistant for VS Code. Connects memory, project context, tools, and a single thinking-partner voice.",
|
||||
"version": "2.59.0",
|
||||
"version": "2.60.0",
|
||||
"publisher": "connectailab",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
|
||||
+65
-76
@@ -37,6 +37,8 @@ import {
|
||||
renderSecondBrainTraceMarkdown,
|
||||
SecondBrainTrace
|
||||
} from './features/secondBrainTrace';
|
||||
import { MemoryManager } from './memory';
|
||||
import { RetrievalOrchestrator } from './retrieval';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
@@ -80,6 +82,8 @@ export class AgentExecutor {
|
||||
private transactionManager: TransactionManager;
|
||||
private sessionManager: SessionManager;
|
||||
private statusBarManager: StatusBarManager;
|
||||
private memoryManager: MemoryManager;
|
||||
private retrievalOrchestrator: RetrievalOrchestrator;
|
||||
private currentTaskId: string = 'default_session';
|
||||
|
||||
constructor(
|
||||
@@ -88,6 +92,17 @@ export class AgentExecutor {
|
||||
this.transactionManager = new TransactionManager();
|
||||
this.sessionManager = new SessionManager(this.context);
|
||||
this.statusBarManager = new StatusBarManager();
|
||||
|
||||
// Initialize 5-Layer Cognitive Memory System
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
this.memoryManager = new MemoryManager(activeBrain.localBrainPath, {
|
||||
enabled: getConfig().memoryEnabled,
|
||||
shortTermLimit: getConfig().memoryShortTermMessages,
|
||||
});
|
||||
|
||||
// Initialize RAG Pipeline Orchestrator
|
||||
this.retrievalOrchestrator = new RetrievalOrchestrator();
|
||||
|
||||
this.restoreLastSession();
|
||||
}
|
||||
|
||||
@@ -142,6 +157,10 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
public clearHistory() {
|
||||
// Extract memories before clearing
|
||||
if (this.chatHistory.length > 2) {
|
||||
this.onSessionEnd();
|
||||
}
|
||||
this.chatHistory = [];
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
@@ -156,6 +175,10 @@ export class AgentExecutor {
|
||||
|
||||
public resetConversation() {
|
||||
this.stop();
|
||||
// Extract memories before resetting
|
||||
if (this.chatHistory.length > 2) {
|
||||
this.onSessionEnd();
|
||||
}
|
||||
this.chatHistory = [];
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
@@ -1692,84 +1715,30 @@ export class AgentExecutor {
|
||||
const config = getConfig();
|
||||
if (!config.memoryEnabled) return '';
|
||||
|
||||
const visibleHistory = this.chatHistory.filter((message) => !message.internal);
|
||||
const shortTerm = visibleHistory
|
||||
.slice(-config.memoryShortTermMessages)
|
||||
.map((message) => `- ${message.role}: ${summarizeText(message.content, 260)}`)
|
||||
.join('\n');
|
||||
|
||||
const savedSessions = this.context.globalState.get<any[]>('chat_sessions', []) || [];
|
||||
const mediumTerm = savedSessions
|
||||
.slice(0, config.memoryMediumTermSessions)
|
||||
.map((session: any) => {
|
||||
const title = summarizeText(String(session?.title || 'Untitled session'), 120);
|
||||
const lastMessage = Array.isArray(session?.history)
|
||||
? session.history[session.history.length - 1]?.content || ''
|
||||
: '';
|
||||
return `- ${title}: ${summarizeText(String(lastMessage), 220)}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const longTerm = this.findRelevantBrainMemory(currentPrompt, config.memoryLongTermFiles, activeBrain);
|
||||
const sections = [
|
||||
shortTerm ? `### Short-Term Memory\n${shortTerm}` : '',
|
||||
mediumTerm ? `### Medium-Term Memory\n${mediumTerm}` : '',
|
||||
longTerm ? `### Long-Term Memory\n${longTerm}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
if (!sections) return '';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
private findRelevantBrainMemory(currentPrompt: string, limit: number, activeBrain: BrainProfile): string {
|
||||
if (limit <= 0) return '';
|
||||
|
||||
try {
|
||||
const files = findBrainFiles(activeBrain.localBrainPath);
|
||||
const terms = currentPrompt
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9가-힣_]+/g)
|
||||
.filter((term) => term.length >= 2)
|
||||
.slice(0, 24);
|
||||
|
||||
const scored = files.map((file) => {
|
||||
let score = 0;
|
||||
const basename = path.basename(file).toLowerCase();
|
||||
for (const term of terms) {
|
||||
if (basename.includes(term)) score += 4;
|
||||
}
|
||||
|
||||
let preview = '';
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lower = content.toLowerCase();
|
||||
for (const term of terms) {
|
||||
if (lower.includes(term)) score += 1;
|
||||
}
|
||||
preview = summarizeText(content, 360);
|
||||
} catch {
|
||||
preview = '';
|
||||
}
|
||||
|
||||
const stat = fs.existsSync(file) ? fs.statSync(file) : undefined;
|
||||
return { file, score, preview, mtime: stat?.mtimeMs || 0 };
|
||||
// Update memory manager config in case settings changed
|
||||
this.memoryManager.updateConfig({
|
||||
enabled: config.memoryEnabled,
|
||||
shortTermLimit: config.memoryShortTermMessages,
|
||||
});
|
||||
|
||||
return scored
|
||||
.sort((a, b) => (b.score - a.score) || (b.mtime - a.mtime))
|
||||
.slice(0, limit)
|
||||
.map((entry) => `- ${path.relative(activeBrain.localBrainPath, entry.file)}: ${entry.preview}`)
|
||||
.join('\n');
|
||||
} catch (error: any) {
|
||||
logError('Failed to build long-term memory context.', { error: error?.message || String(error) });
|
||||
return '';
|
||||
}
|
||||
const visibleHistory = this.chatHistory.filter((message) => !message.internal);
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
|
||||
|
||||
// Use the Unified RAG Pipeline
|
||||
const result = this.retrievalOrchestrator.retrieve(currentPrompt, {
|
||||
brain: activeBrain,
|
||||
memoryManager: this.memoryManager,
|
||||
workspacePath,
|
||||
chatHistory: visibleHistory,
|
||||
contextBudget: {
|
||||
totalBudget: 8000,
|
||||
retrievalRatio: 0.4
|
||||
},
|
||||
brainFileLimit: config.memoryLongTermFiles
|
||||
});
|
||||
|
||||
return this.retrievalOrchestrator.buildContextString(result);
|
||||
}
|
||||
|
||||
private emitHistoryChanged() {
|
||||
@@ -1787,6 +1756,26 @@ export class AgentExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 종료 시 5-Layer Memory에 자동 추출을 수행합니다.
|
||||
* 새 채팅 시작 또는 Extension 비활성화 시 호출됩니다.
|
||||
*/
|
||||
public onSessionEnd(): void {
|
||||
try {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
|
||||
|
||||
this.memoryManager.onSessionEnd(
|
||||
this.currentTaskId,
|
||||
this.chatHistory.filter((m) => !m.internal),
|
||||
workspacePath
|
||||
);
|
||||
logInfo('Memory extraction completed for session end.', { taskId: this.currentTaskId });
|
||||
} catch (error: any) {
|
||||
logError('Memory extraction failed on session end.', { error: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
private async createStreamingRequest(params: {
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { findBrainFiles, summarizeText } from '../utils';
|
||||
import { expandQuery, scoreTfIdf, extractBestExcerpt, tokenize as scoringTokenize } from '../retrieval/scoring';
|
||||
|
||||
export type SecondBrainSourceType = 'Project Evidence' | 'User Decision' | 'General Knowledge' | 'Reference Only';
|
||||
export type SecondBrainQueryIntent = 'technical' | 'ux-business' | 'governance' | 'general';
|
||||
@@ -547,16 +548,7 @@ function isStructuredKnowledgeRequest(query: string): boolean {
|
||||
}
|
||||
|
||||
function tokenize(value: string): string[] {
|
||||
const stopWords = new Set([
|
||||
'그리고', '그런데', '해서', '하는', '있어', '아래', '문제점들을', '해결하기', '위해서',
|
||||
'어떻게', '대응해야할지', '가이드를', '작성해줘', '필요', '지점', '보완',
|
||||
'what', 'how', 'the', 'and', 'for', 'with', 'please', 'write', 'guide', 'recommendations'
|
||||
]);
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9가-힣_]+/g)
|
||||
.map((term) => term.trim())
|
||||
.filter((term) => term.length >= 2 && !stopWords.has(term));
|
||||
return scoringTokenize(value);
|
||||
}
|
||||
|
||||
function inferTargetProject(query: string): string | undefined {
|
||||
@@ -588,21 +580,23 @@ function scoreFile(file: string, brainRoot: string, terms: string[], intent: Sec
|
||||
if (targetProject) {
|
||||
score += projectRelevanceScore(relative, lower, targetProject, documentProject);
|
||||
}
|
||||
for (const term of terms) {
|
||||
if (basename.includes(term)) score += 4;
|
||||
const matches = lower.split(term).length - 1;
|
||||
if (matches > 0) score += knowledgeRole === 'routing-hint' ? Math.min(matches, 1) : Math.min(matches, 6);
|
||||
}
|
||||
const expandedTerms = expandQuery(terms);
|
||||
const scoredTfIdf = scoreTfIdf(expandedTerms, [{ title, content, lastModified: Date.now() }])[0];
|
||||
|
||||
score += scoredTfIdf.score;
|
||||
|
||||
if (knowledgeRole === 'routing-hint') {
|
||||
score -= 8;
|
||||
}
|
||||
|
||||
const finalExcerpt = extractBestExcerpt(content, expandedTerms, 420);
|
||||
|
||||
return {
|
||||
title,
|
||||
path: relative,
|
||||
absolutePath: file,
|
||||
score: Number((Math.max(score, 0) / Math.max(terms.length, 1)).toFixed(2)),
|
||||
excerpt: summarizeText(bestExcerpt(content, terms), 420),
|
||||
score: Number((Math.max(score, 0) / Math.max(expandedTerms.length, 1)).toFixed(2)),
|
||||
excerpt: summarizeText(finalExcerpt, 420),
|
||||
sourceType,
|
||||
knowledgeRole,
|
||||
canSupportProjectClaim,
|
||||
@@ -705,25 +699,7 @@ function pathPriority(relativePath: string, intent: SecondBrainQueryIntent): num
|
||||
return score;
|
||||
}
|
||||
|
||||
function bestExcerpt(content: string, terms: string[]): string {
|
||||
const paragraphs = content
|
||||
.split(/\n\s*\n/g)
|
||||
.map((part) => part.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
if (paragraphs.length === 0) return '';
|
||||
|
||||
let best = paragraphs[0];
|
||||
let bestScore = -1;
|
||||
for (const paragraph of paragraphs) {
|
||||
const lower = paragraph.toLowerCase();
|
||||
const score = terms.reduce((sum, term) => sum + (lower.includes(term) ? 1 : 0), 0);
|
||||
if (score > bestScore) {
|
||||
best = paragraph;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
// bestExcerpt is replaced by extractBestExcerpt from scoring.ts
|
||||
|
||||
function inferCollections(docs: SecondBrainTraceDocument[]): string[] {
|
||||
const collections = new Set<string>();
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Context Budget Manager (컨텍스트 예산 관리)
|
||||
*
|
||||
* 시스템 프롬프트의 토큰 예산을 관리하여
|
||||
* 로컬 모델의 context window를 효율적으로 활용합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import { RetrievalChunk, ContextBudgetConfig } from './types';
|
||||
|
||||
const DEFAULT_BUDGET: ContextBudgetConfig = {
|
||||
totalBudget: 8000, // ~32K context 중 retrieval에 할당
|
||||
retrievalRatio: 0.4, // 40%
|
||||
minChunks: 2,
|
||||
maxChunks: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
|
||||
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
// 한국어 비율 추정
|
||||
const koreanChars = (text.match(/[가-힣]/g) || []).length;
|
||||
const totalChars = text.length;
|
||||
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
|
||||
|
||||
// 한국어는 글자당 ~1.5 토큰, 영어는 ~0.25 토큰
|
||||
const koreanTokens = koreanChars * 1.5;
|
||||
const otherTokens = (totalChars - koreanChars) * 0.25;
|
||||
|
||||
return Math.ceil(koreanTokens + otherTokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
|
||||
*
|
||||
* 선택 전략:
|
||||
* 1. 스코어 내림차순 정렬
|
||||
* 2. 중복 제거 (같은 filePath를 가진 청크)
|
||||
* 3. 토큰 예산 내에서 순서대로 선택
|
||||
* 4. 최소 청크 수 보장
|
||||
*/
|
||||
export function selectWithinBudget(
|
||||
chunks: RetrievalChunk[],
|
||||
config: Partial<ContextBudgetConfig> = {}
|
||||
): { selected: RetrievalChunk[]; dropped: RetrievalChunk[]; tokensUsed: number } {
|
||||
const cfg = { ...DEFAULT_BUDGET, ...config };
|
||||
const budget = Math.floor(cfg.totalBudget * cfg.retrievalRatio);
|
||||
|
||||
// 1. Sort by score descending
|
||||
const sorted = [...chunks].sort((a, b) => b.score - a.score);
|
||||
|
||||
// 2. Deduplicate by filePath
|
||||
const seen = new Set<string>();
|
||||
const deduped = sorted.filter((chunk) => {
|
||||
const key = chunk.metadata.filePath || chunk.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3. Select within budget
|
||||
const selected: RetrievalChunk[] = [];
|
||||
const dropped: RetrievalChunk[] = [];
|
||||
let tokensUsed = 0;
|
||||
|
||||
for (const chunk of deduped) {
|
||||
const chunkTokens = chunk.tokenEstimate || estimateTokens(chunk.content);
|
||||
|
||||
if (selected.length >= cfg.maxChunks) {
|
||||
dropped.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokensUsed + chunkTokens > budget && selected.length >= cfg.minChunks) {
|
||||
dropped.push(chunk);
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(chunk);
|
||||
tokensUsed += chunkTokens;
|
||||
}
|
||||
|
||||
return { selected, dropped, tokensUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다.
|
||||
* 소스별로 그룹화하여 가독성을 높입니다.
|
||||
*/
|
||||
export function assembleContext(chunks: RetrievalChunk[]): string {
|
||||
if (chunks.length === 0) return '';
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
'brain-trace': '📚 Second Brain Knowledge',
|
||||
'brain-memory': '📚 Brain Knowledge',
|
||||
'long-term-memory': '🧠 Long-Term Memory (사용자 규칙/결정)',
|
||||
'project-memory': '📂 Project Memory (프로젝트 컨텍스트)',
|
||||
'procedural-memory': '📋 Procedural Memory (반복 절차)',
|
||||
'episodic-memory': '📖 Episodic Memory (과거 대화 흐름)',
|
||||
'project-scan': '🔍 Project Scan',
|
||||
'recent-knowledge': '📄 Recent Project Knowledge'
|
||||
};
|
||||
|
||||
// Group by source
|
||||
const groups = new Map<string, RetrievalChunk[]>();
|
||||
for (const chunk of chunks) {
|
||||
const key = chunk.source;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(chunk);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const [source, groupChunks] of groups) {
|
||||
const label = sourceLabels[source] || source;
|
||||
const items = groupChunks
|
||||
.map((c) => `- ${c.title}: ${c.content}`)
|
||||
.join('\n');
|
||||
sections.push(`### ${label}\n${items}`);
|
||||
}
|
||||
|
||||
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\n')
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* RetrievalOrchestrator — Unified RAG Pipeline
|
||||
*
|
||||
* Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다.
|
||||
*
|
||||
* 검색 흐름:
|
||||
* ① Query Planning — 의도 분류 + 검색 전략 결정
|
||||
* ② Parallel Search — Brain + Memory + Project + Episode 동시 검색
|
||||
* ③ Result Fusion — 통합 스코어링 + 중복 제거
|
||||
* ④ Context Budget — 토큰 예산 내에서 최종 선택
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { BrainProfile } from '../config';
|
||||
import { findBrainFiles, summarizeText } from '../utils';
|
||||
import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
import { tokenize, expandQuery, scoreTfIdf, extractBestExcerpt } from './scoring';
|
||||
import { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
|
||||
export { tokenize, expandQuery, scoreTfIdf, extractBestExcerpt } from './scoring';
|
||||
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
export * from './types';
|
||||
|
||||
interface RetrievalOptions {
|
||||
brain: BrainProfile;
|
||||
memoryManager: MemoryManager;
|
||||
workspacePath?: string;
|
||||
chatHistory?: Array<{ role: string; content: string }>;
|
||||
contextBudget?: Partial<ContextBudgetConfig>;
|
||||
brainFileLimit?: number;
|
||||
includeRawConversations?: boolean;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
/**
|
||||
* 통합 검색을 수행합니다.
|
||||
* 모든 소스에서 검색 → TF-IDF 스코어링 → 중복 제거 → 예산 내 선택
|
||||
*/
|
||||
public retrieve(query: string, options: RetrievalOptions): RetrievalResult {
|
||||
const fusionLog: string[] = [];
|
||||
const allChunks: RetrievalChunk[] = [];
|
||||
const queryTokens = tokenize(query);
|
||||
const expandedTokens = expandQuery(queryTokens);
|
||||
|
||||
fusionLog.push(`Query tokens: [${queryTokens.slice(0, 10).join(', ')}]`);
|
||||
fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`);
|
||||
|
||||
// ── ① Brain File Search (TF-IDF enhanced) ──
|
||||
const brainChunks = this.searchBrainFiles(
|
||||
query,
|
||||
expandedTokens,
|
||||
options.brain,
|
||||
options.brainFileLimit || 8,
|
||||
options.includeRawConversations || false
|
||||
);
|
||||
allChunks.push(...brainChunks);
|
||||
fusionLog.push(`Brain search: ${brainChunks.length} chunks found`);
|
||||
|
||||
// ── ② Memory Layers ──
|
||||
const memoryChunks = this.searchMemoryLayers(
|
||||
query,
|
||||
options.memoryManager,
|
||||
options.chatHistory || [],
|
||||
options.workspacePath
|
||||
);
|
||||
allChunks.push(...memoryChunks);
|
||||
fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`);
|
||||
|
||||
// ── ③ Result Fusion — normalize scores across sources ──
|
||||
this.normalizeScores(allChunks);
|
||||
fusionLog.push(`Total chunks before budget: ${allChunks.length}`);
|
||||
|
||||
// ── ④ Context Budget Selection ──
|
||||
const { selected, dropped, tokensUsed } = selectWithinBudget(
|
||||
allChunks,
|
||||
options.contextBudget
|
||||
);
|
||||
fusionLog.push(`Selected: ${selected.length}, Dropped: ${dropped.length}, Tokens: ${tokensUsed}`);
|
||||
|
||||
return {
|
||||
query,
|
||||
totalChunks: allChunks.length,
|
||||
selectedChunks: selected,
|
||||
droppedChunks: dropped,
|
||||
totalTokensUsed: tokensUsed,
|
||||
contextBudget: options.contextBudget?.totalBudget || 8000,
|
||||
fusionLog
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과를 최종 컨텍스트 문자열로 변환합니다.
|
||||
*/
|
||||
public buildContextString(result: RetrievalResult): string {
|
||||
return assembleContext(result.selectedChunks);
|
||||
}
|
||||
|
||||
// ─── Brain File Search ───
|
||||
|
||||
private searchBrainFiles(
|
||||
query: string,
|
||||
expandedTokens: string[],
|
||||
brain: BrainProfile,
|
||||
limit: number,
|
||||
includeRaw: boolean
|
||||
): RetrievalChunk[] {
|
||||
try {
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
.filter((file) => includeRaw || !this.isRawConversation(path.relative(brain.localBrainPath, file)));
|
||||
|
||||
if (allFiles.length === 0) return [];
|
||||
|
||||
// Read all files for TF-IDF
|
||||
const documents = allFiles.map((file) => {
|
||||
let content = '';
|
||||
let lastModified = 0;
|
||||
try {
|
||||
content = fs.readFileSync(file, 'utf8');
|
||||
lastModified = fs.statSync(file).mtimeMs;
|
||||
} catch { /* skip */ }
|
||||
return {
|
||||
title: path.basename(file, '.md'),
|
||||
content,
|
||||
lastModified,
|
||||
filePath: file,
|
||||
relativePath: path.relative(brain.localBrainPath, file)
|
||||
};
|
||||
});
|
||||
|
||||
// TF-IDF scoring
|
||||
const scored = scoreTfIdf(expandedTokens, documents);
|
||||
|
||||
return scored
|
||||
.filter((s) => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((scored) => {
|
||||
const doc = documents[scored.index];
|
||||
const excerpt = extractBestExcerpt(doc.content, expandedTokens, 400);
|
||||
return {
|
||||
id: `brain-${scored.index}`,
|
||||
source: 'brain-memory' as const,
|
||||
title: doc.relativePath,
|
||||
content: summarizeText(excerpt, 400),
|
||||
score: scored.score,
|
||||
tokenEstimate: estimateTokens(excerpt),
|
||||
metadata: {
|
||||
filePath: doc.filePath,
|
||||
category: this.inferCategory(doc.relativePath),
|
||||
isProjectEvidence: this.isProjectEvidence(doc.relativePath, doc.content),
|
||||
lastUpdated: doc.lastModified
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory Layer Search ───
|
||||
|
||||
private searchMemoryLayers(
|
||||
query: string,
|
||||
memoryManager: MemoryManager,
|
||||
chatHistory: Array<{ role: string; content: string }>,
|
||||
workspacePath?: string
|
||||
): RetrievalChunk[] {
|
||||
const chunks: RetrievalChunk[] = [];
|
||||
|
||||
// Long-Term Memory
|
||||
const ltm = memoryManager.getLongTermMemory();
|
||||
const ltmContext = ltm.buildContext(query);
|
||||
if (ltmContext) {
|
||||
chunks.push({
|
||||
id: 'ltm-context',
|
||||
source: 'long-term-memory',
|
||||
title: ltmContext.label,
|
||||
content: ltmContext.content,
|
||||
score: ltmContext.relevance,
|
||||
tokenEstimate: estimateTokens(ltmContext.content),
|
||||
metadata: { category: 'long-term' }
|
||||
});
|
||||
}
|
||||
|
||||
// Project Memory
|
||||
if (workspacePath) {
|
||||
const pm = memoryManager.getProjectMemory(workspacePath);
|
||||
const pmContext = pm.buildContext(query);
|
||||
if (pmContext) {
|
||||
chunks.push({
|
||||
id: 'pm-context',
|
||||
source: 'project-memory',
|
||||
title: pmContext.label,
|
||||
content: pmContext.content,
|
||||
score: pmContext.relevance,
|
||||
tokenEstimate: estimateTokens(pmContext.content),
|
||||
metadata: { category: 'project', isProjectEvidence: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Procedural Memory
|
||||
const proc = memoryManager.getProceduralMemory();
|
||||
const procContext = proc.buildContext(query);
|
||||
if (procContext) {
|
||||
chunks.push({
|
||||
id: 'proc-context',
|
||||
source: 'procedural-memory',
|
||||
title: procContext.label,
|
||||
content: procContext.content,
|
||||
score: procContext.relevance,
|
||||
tokenEstimate: estimateTokens(procContext.content),
|
||||
metadata: { category: 'procedural' }
|
||||
});
|
||||
}
|
||||
|
||||
// Episodic Memory
|
||||
const ep = memoryManager.getEpisodicMemory();
|
||||
const epContext = ep.buildContext(query);
|
||||
if (epContext) {
|
||||
chunks.push({
|
||||
id: 'ep-context',
|
||||
source: 'episodic-memory',
|
||||
title: epContext.label,
|
||||
content: epContext.content,
|
||||
score: epContext.relevance,
|
||||
tokenEstimate: estimateTokens(epContext.content),
|
||||
metadata: { category: 'episodic' }
|
||||
});
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// ─── Score Normalization ───
|
||||
|
||||
/**
|
||||
* 서로 다른 스코어 스케일을 가진 소스들의 점수를 0~1로 정규화합니다.
|
||||
*/
|
||||
private normalizeScores(chunks: RetrievalChunk[]): void {
|
||||
// Group by source
|
||||
const groups = new Map<string, RetrievalChunk[]>();
|
||||
for (const chunk of chunks) {
|
||||
if (!groups.has(chunk.source)) groups.set(chunk.source, []);
|
||||
groups.get(chunk.source)!.push(chunk);
|
||||
}
|
||||
|
||||
// Normalize each group independently
|
||||
for (const [, group] of groups) {
|
||||
const maxScore = Math.max(...group.map((c) => c.score), 0.001);
|
||||
for (const chunk of group) {
|
||||
chunk.score = chunk.score / maxScore;
|
||||
}
|
||||
}
|
||||
|
||||
// Source priority boost (some sources are inherently more valuable for RAG)
|
||||
const sourceBoost: Record<string, number> = {
|
||||
'brain-trace': 1.0,
|
||||
'brain-memory': 0.9,
|
||||
'project-memory': 0.85,
|
||||
'long-term-memory': 0.8,
|
||||
'procedural-memory': 0.95, // Procedural is highly specific
|
||||
'episodic-memory': 0.7,
|
||||
'project-scan': 0.6,
|
||||
'recent-knowledge': 0.75
|
||||
};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const boost = sourceBoost[chunk.source] || 0.5;
|
||||
chunk.score *= boost;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
private isRawConversation(relativePath: string): boolean {
|
||||
return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
|
||||
}
|
||||
|
||||
private inferCategory(relativePath: string): string {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(decisions?|adr|planning)/i.test(normalized)) return 'decision';
|
||||
if (/(records|development|bugs)/i.test(normalized)) return 'project-record';
|
||||
if (/(architecture|design|pattern)/i.test(normalized)) return 'architecture';
|
||||
if (/(knowledge|wiki|topics)/i.test(normalized)) return 'knowledge';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
private isProjectEvidence(relativePath: string, content: string): boolean {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(records|planning|development|bugs|retrospectives|projectchronicle)/i.test(normalized)) return true;
|
||||
if (/adr-\d+|(^|[\\/])decisions?([\\/]|$)/i.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Scoring Engine — TF-IDF + Bilingual Tokenizer
|
||||
*
|
||||
* 단순 includes() 키워드 매칭을 넘어서,
|
||||
* TF-IDF 가중치 기반의 문서 스코어링을 제공합니다.
|
||||
* 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
// ─── Bilingual Tokenizer ───
|
||||
|
||||
const STOP_WORDS_EN = new Set([
|
||||
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
||||
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
||||
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
||||
'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those',
|
||||
'it', 'its', 'not', 'no', 'what', 'how', 'when', 'where', 'which',
|
||||
'who', 'whom', 'why', 'if', 'then', 'than', 'so', 'as', 'just',
|
||||
'about', 'also', 'more', 'some', 'very', 'all', 'each', 'every',
|
||||
'such', 'please', 'write', 'use', 'using', 'used'
|
||||
]);
|
||||
|
||||
const STOP_WORDS_KO = new Set([
|
||||
'그리고', '그런데', '그래서', '하지만', '또한', '또는', '해서', '하는',
|
||||
'있어', '없어', '아래', '위에', '어떻게', '이것', '저것', '그것',
|
||||
'이런', '저런', '그런', '여기', '거기', '필요', '사용', '관련',
|
||||
'대한', '대해', '통해', '따라', '위해', '대로', '만큼'
|
||||
]);
|
||||
|
||||
/**
|
||||
* 한국어/영어 혼합 텍스트를 토큰으로 분리합니다.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9가-힣_.-]+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 2)
|
||||
.filter((t) => !STOP_WORDS_EN.has(t) && !STOP_WORDS_KO.has(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* 동의어/관련어 확장을 수행합니다.
|
||||
*/
|
||||
export function expandQuery(tokens: string[]): string[] {
|
||||
const synonymMap: Record<string, string[]> = {
|
||||
'성능': ['performance', 'optimization', '최적화', 'speed'],
|
||||
'performance': ['성능', '최적화', 'optimization', 'speed'],
|
||||
'아키텍처': ['architecture', '구조', 'structure', 'design'],
|
||||
'architecture': ['아키텍처', '구조', 'structure', 'design'],
|
||||
'메모리': ['memory', '기억', 'cache', 'storage'],
|
||||
'memory': ['메모리', '기억', 'cache', 'storage'],
|
||||
'버그': ['bug', 'error', '오류', 'issue', 'defect'],
|
||||
'bug': ['버그', 'error', '오류', 'issue'],
|
||||
'설계': ['design', '아키텍처', 'architecture', 'pattern'],
|
||||
'design': ['설계', '아키텍처', 'architecture', 'pattern'],
|
||||
'배포': ['deploy', 'deployment', 'release', 'ci', 'cd'],
|
||||
'deploy': ['배포', 'deployment', 'release'],
|
||||
'테스트': ['test', 'testing', 'spec', 'jest', 'mocha'],
|
||||
'test': ['테스트', 'testing', 'spec'],
|
||||
'프로젝트': ['project', '프로그램', 'repo', 'repository'],
|
||||
'project': ['프로젝트', '프로그램', 'repo'],
|
||||
'방향': ['direction', '전략', 'strategy', '목표', 'goal'],
|
||||
'direction': ['방향', '전략', 'strategy', '목표']
|
||||
};
|
||||
|
||||
const expanded = new Set(tokens);
|
||||
for (const token of tokens) {
|
||||
const synonyms = synonymMap[token];
|
||||
if (synonyms) {
|
||||
for (const syn of synonyms) {
|
||||
expanded.add(syn);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(expanded);
|
||||
}
|
||||
|
||||
// ─── TF-IDF Scoring ───
|
||||
|
||||
/**
|
||||
* TF (Term Frequency): 문서 내 용어 빈도
|
||||
*/
|
||||
function termFrequency(term: string, documentTokens: string[]): number {
|
||||
if (documentTokens.length === 0) return 0;
|
||||
const count = documentTokens.filter((t) => t === term).length;
|
||||
return count / documentTokens.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDF (Inverse Document Frequency): 전체 문서 대비 희소도
|
||||
*/
|
||||
function inverseDocumentFrequency(
|
||||
term: string,
|
||||
allDocumentTokenSets: Array<Set<string>>
|
||||
): number {
|
||||
const containing = allDocumentTokenSets.filter((doc) => doc.has(term)).length;
|
||||
return Math.log((allDocumentTokenSets.length + 1) / (containing + 1)) + 1;
|
||||
}
|
||||
|
||||
export interface ScoredDocument {
|
||||
index: number;
|
||||
score: number;
|
||||
titleBoost: number;
|
||||
recencyBoost: number;
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TF-IDF 기반으로 문서 집합을 스코어링합니다.
|
||||
*/
|
||||
export function scoreTfIdf(
|
||||
queryTokens: string[],
|
||||
documents: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
lastModified?: number;
|
||||
}>
|
||||
): ScoredDocument[] {
|
||||
if (documents.length === 0 || queryTokens.length === 0) return [];
|
||||
|
||||
// Pre-tokenize all documents
|
||||
const docTokenArrays = documents.map((doc) =>
|
||||
tokenize(`${doc.title} ${doc.content}`)
|
||||
);
|
||||
const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
|
||||
|
||||
// Expand query with synonyms
|
||||
const expandedQuery = expandQuery(queryTokens);
|
||||
|
||||
// Compute IDF for each query term
|
||||
const idfCache = new Map<string, number>();
|
||||
for (const term of expandedQuery) {
|
||||
if (!idfCache.has(term)) {
|
||||
idfCache.set(term, inverseDocumentFrequency(term, docTokenSets));
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
return documents.map((doc, index) => {
|
||||
const docTokens = docTokenArrays[index];
|
||||
const titleTokens = new Set(tokenize(doc.title));
|
||||
let score = 0;
|
||||
const matchedTerms: string[] = [];
|
||||
|
||||
for (const term of expandedQuery) {
|
||||
const tf = termFrequency(term, docTokens);
|
||||
const idf = idfCache.get(term) || 1;
|
||||
const tfidf = tf * idf;
|
||||
|
||||
if (tfidf > 0) {
|
||||
matchedTerms.push(term);
|
||||
}
|
||||
|
||||
// Title match bonus (3x)
|
||||
const titleMultiplier = titleTokens.has(term) ? 3.0 : 1.0;
|
||||
score += tfidf * titleMultiplier;
|
||||
}
|
||||
|
||||
// Recency boost: documents modified recently get a boost
|
||||
let recencyBoost = 0;
|
||||
if (doc.lastModified) {
|
||||
const daysAgo = (now - doc.lastModified) / (1000 * 60 * 60 * 24);
|
||||
if (daysAgo < 1) recencyBoost = 0.3;
|
||||
else if (daysAgo < 7) recencyBoost = 0.2;
|
||||
else if (daysAgo < 30) recencyBoost = 0.1;
|
||||
}
|
||||
|
||||
// Title match bonus for exact query term presence
|
||||
const titleBoost = queryTokens.some((t) => titleTokens.has(t)) ? 0.2 : 0;
|
||||
|
||||
return {
|
||||
index,
|
||||
score: score + recencyBoost + titleBoost,
|
||||
titleBoost,
|
||||
recencyBoost,
|
||||
matchedTerms: [...new Set(matchedTerms)]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 가장 관련성 높은 구간(excerpt)을 추출합니다.
|
||||
* 단순 paragraph 단위가 아니라, 키워드 밀도가 높은 윈도우를 찾습니다.
|
||||
*/
|
||||
export function extractBestExcerpt(
|
||||
content: string,
|
||||
queryTokens: string[],
|
||||
maxLength = 500
|
||||
): string {
|
||||
const expanded = expandQuery(queryTokens);
|
||||
const expandedSet = new Set(expanded);
|
||||
|
||||
// Split into sentences (한국어 + 영어)
|
||||
const sentences = content
|
||||
.split(/(?<=[.!?。!?\n])\s*/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 10);
|
||||
|
||||
if (sentences.length === 0) return content.slice(0, maxLength);
|
||||
|
||||
// Score each sentence
|
||||
const scored = sentences.map((sentence, idx) => {
|
||||
const tokens = tokenize(sentence);
|
||||
const matchCount = tokens.filter((t) => expandedSet.has(t)).length;
|
||||
const density = tokens.length > 0 ? matchCount / tokens.length : 0;
|
||||
return { sentence, idx, matchCount, density };
|
||||
});
|
||||
|
||||
// Find the best window of consecutive sentences
|
||||
let bestStart = 0;
|
||||
let bestScore = -1;
|
||||
let bestLen = 0;
|
||||
|
||||
for (let i = 0; i < scored.length; i++) {
|
||||
let windowText = '';
|
||||
let windowScore = 0;
|
||||
let j = i;
|
||||
|
||||
while (j < scored.length && windowText.length < maxLength) {
|
||||
windowText += scored[j].sentence + ' ';
|
||||
windowScore += scored[j].matchCount + scored[j].density * 2;
|
||||
j++;
|
||||
}
|
||||
|
||||
if (windowScore > bestScore) {
|
||||
bestScore = windowScore;
|
||||
bestStart = i;
|
||||
bestLen = j - i;
|
||||
}
|
||||
}
|
||||
|
||||
const excerptSentences = scored
|
||||
.slice(bestStart, bestStart + bestLen)
|
||||
.map((s) => s.sentence);
|
||||
|
||||
const result = excerptSentences.join(' ');
|
||||
return result.length > maxLength ? result.slice(0, maxLength - 3) + '...' : result;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Retrieval Types (검색 결과 통합 타입)
|
||||
*
|
||||
* 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를
|
||||
* 통합 인터페이스로 정의합니다.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
export type RetrievalSource =
|
||||
| 'brain-trace' // Second Brain Trace
|
||||
| 'brain-memory' // findRelevantBrainMemory (legacy)
|
||||
| 'long-term-memory' // Long-Term Memory
|
||||
| 'project-memory' // Project Memory
|
||||
| 'procedural-memory' // Procedural Memory
|
||||
| 'episodic-memory' // Episodic Memory
|
||||
| 'project-scan' // Local Project Path scan
|
||||
| 'recent-knowledge'; // Recent Project Knowledge record
|
||||
|
||||
export interface RetrievalChunk {
|
||||
id: string;
|
||||
source: RetrievalSource;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number; // 0.0 ~ 1.0 normalized
|
||||
tokenEstimate: number; // rough character / 4
|
||||
metadata: {
|
||||
filePath?: string;
|
||||
category?: string;
|
||||
isProjectEvidence?: boolean;
|
||||
lastUpdated?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RetrievalResult {
|
||||
query: string;
|
||||
totalChunks: number;
|
||||
selectedChunks: RetrievalChunk[];
|
||||
droppedChunks: RetrievalChunk[];
|
||||
totalTokensUsed: number;
|
||||
contextBudget: number;
|
||||
fusionLog: string[]; // 디버그용 융합 로그
|
||||
}
|
||||
|
||||
export interface ContextBudgetConfig {
|
||||
totalBudget: number; // 전체 토큰 예산
|
||||
retrievalRatio: number; // 검색 결과 비율 (0.0~1.0)
|
||||
minChunks: number; // 최소 포함 청크 수
|
||||
maxChunks: number; // 최대 포함 청크 수
|
||||
}
|
||||
Reference in New Issue
Block a user