chore: release v2.2.46 with critical bug fixes for AI communication and brain management

This commit is contained in:
Wonseok Jung
2026-04-25 19:07:15 +09:00
parent acc6c76a4f
commit 42ca873d45
6 changed files with 1176 additions and 129 deletions
+544 -50
View File
@@ -7,10 +7,11 @@ import {
_getBrainDir,
findBrainFiles,
EXCLUDED_DIRS,
SYSTEM_PROMPT,
getSystemPrompt,
shouldAutoPushBrain,
getSecondBrainRepo,
buildApiUrl,
getActiveBrainProfile,
logError,
logInfo,
resolveEngine,
@@ -24,10 +25,15 @@ export interface ChatMessage {
internal?: boolean;
}
type HistoryChangeListener = (history: ChatMessage[]) => void | Promise<void>;
export class AgentExecutor {
private chatHistory: ChatMessage[] = [];
private abortController: AbortController | null = null;
private webview: vscode.Webview | undefined;
private historyChangeListener: HistoryChangeListener | undefined;
private runSerial = 0;
private activeRunId = 0;
constructor(
private context: vscode.ExtensionContext
@@ -37,25 +43,38 @@ export class AgentExecutor {
this.webview = webview;
}
public setHistoryChangeListener(listener: HistoryChangeListener) {
this.historyChangeListener = listener;
}
public getHistory() {
return this.chatHistory.filter(message => !message.internal);
}
public setHistory(history: ChatMessage[]) {
this.chatHistory = history;
this.emitHistoryChanged();
}
public clearHistory() {
this.chatHistory = [];
this.emitHistoryChanged();
}
public stop() {
this.activeRunId = ++this.runSerial;
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
public resetConversation() {
this.stop();
this.chatHistory = [];
this.emitHistoryChanged();
}
public async handlePrompt(
prompt: string | null,
modelName: string,
@@ -65,7 +84,8 @@ export class AgentExecutor {
loopDepth?: number,
visionContent?: any[],
temperature?: number,
systemPrompt?: string
systemPrompt?: string,
runId?: number
}
) {
const {
@@ -74,22 +94,60 @@ export class AgentExecutor {
loopDepth = 0,
visionContent,
temperature = 0.7,
systemPrompt = SYSTEM_PROMPT
systemPrompt = getSystemPrompt()
} = options;
const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId);
const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
if (!this.webview) return;
try {
if (loopDepth === 0) {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.activeRunId = runId;
await this.context.workspaceState.update('lastActionStr', undefined);
}
if (prompt !== null && loopDepth === 0 && !hasVisionContent) {
const localReply = await this.tryHandleLocalTask(prompt);
if (this.isStaleRun(runId)) return;
if (localReply) {
this.chatHistory.push({ role: 'user', content: prompt });
this.emitHistoryChanged();
this.webview.postMessage({ type: 'streamStart' });
this.webview.postMessage({ type: 'streamChunk', value: localReply });
this.webview.postMessage({ type: 'streamEnd' });
this.chatHistory.push({ role: 'assistant', content: localReply });
this.emitHistoryChanged();
return;
}
}
// 1. Prepare Context
const workspaceFolders = vscode.workspace.workspaceFolders;
const rootPath = workspaceFolders ? workspaceFolders[0].uri.fsPath : '';
let contextBlock = '';
const config = getConfig();
const activeBrain = getActiveBrainProfile();
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
const brainPreview = brainFiles
.slice(0, 30)
.map(file => path.relative(activeBrain.localBrainPath, file))
.join('\n');
const brainContext = [
`[ACTIVE SECOND BRAIN]`,
`Use this Local Brain only when it is relevant to the user's current question.`,
`Name: ${activeBrain.name}`,
`Path: ${activeBrain.localBrainPath}`,
`Knowledge files: ${brainFiles.length}`,
activeBrain.description ? `Description: ${activeBrain.description}` : '',
brainPreview ? `Available file examples:\n${brainPreview}` : 'Files: none found'
].filter(Boolean).join('\n');
const editor = vscode.window.activeTextEditor;
if (editor && editor.document.uri.scheme === 'file') {
const text = editor.document.getText();
@@ -103,7 +161,7 @@ export class AgentExecutor {
if (prompt !== null) {
if (loopDepth === 0) {
this.chatHistory.push({ role: 'user', content: prompt });
this.webview.postMessage({ type: 'streamChunk', value: '' }); // Trigger UI update if needed
this.emitHistoryChanged();
} else {
this.chatHistory.push({ role: 'system', content: prompt, internal: true });
}
@@ -114,10 +172,18 @@ export class AgentExecutor {
const reqMessages = [...this.chatHistory];
// Handle Vision Content Injection
if (visionContent && reqMessages.length > 0) {
// Merge text prompt with file content instead of replacing, so the user's message is never lost
if (hasVisionContent && reqMessages.length > 0) {
const lastUserIdx = reqMessages.map(m => m.role).lastIndexOf('user');
if (lastUserIdx >= 0) {
reqMessages[lastUserIdx] = { role: 'user', content: visionContent };
const existingContent = reqMessages[lastUserIdx].content;
const textParts: any[] = (typeof existingContent === 'string' && existingContent.trim())
? [{ type: 'text', text: existingContent }]
: [];
reqMessages[lastUserIdx] = {
role: 'user',
content: [...textParts, ...(visionContent || [])]
};
}
}
@@ -125,7 +191,7 @@ export class AgentExecutor {
const internetCtx = internetEnabled
? `\n\n[CRITICAL: INTERNET ACCESS ENABLED]\nYou can use <read_url> to search. Current time: ${new Date().toLocaleString()}`
: '';
const fullSystemPrompt = `${systemPrompt}${internetCtx}\n\n[CONTEXT]\n${contextBlock}`;
const fullSystemPrompt = `${systemPrompt}${internetCtx}\n\n[CONTEXT]\n${brainContext}\n${contextBlock}`;
const messagesForRequest: ChatMessage[] = [
{ role: 'system', content: fullSystemPrompt, internal: true },
...reqMessages
@@ -133,6 +199,10 @@ export class AgentExecutor {
// 4. Call AI Engine
this.abortController = new AbortController();
requestTimeoutHandle = setTimeout(() => {
logError('AI request timed out.', { timeoutMs: timeout, model: modelName || defaultModel, loopDepth });
this.abortController?.abort();
}, timeout);
const request = await this.createStreamingRequest({
baseUrl: ollamaUrl,
modelName: modelName || defaultModel,
@@ -140,6 +210,7 @@ export class AgentExecutor {
temperature
});
const { response, engine, apiUrl } = request;
if (this.isStaleRun(runId)) return;
let aiResponseText = '';
const reader = response.body?.getReader();
@@ -153,6 +224,7 @@ export class AgentExecutor {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (this.isStaleRun(runId)) return;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
@@ -166,7 +238,6 @@ export class AgentExecutor {
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
this.webview?.postMessage({ type: 'streamChunk', value: token });
}
} catch (e: any) {
logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) });
@@ -191,26 +262,41 @@ export class AgentExecutor {
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
this.webview?.postMessage({ type: 'streamChunk', value: token });
}
} catch (e: any) {
logError('Failed to parse final streaming buffer.', { engine, apiUrl, buffer: summarizeText(buffer, 300), error: e?.message || String(e) });
}
}
this.chatHistory.push({ role: 'assistant', content: aiResponseText });
if (this.isStaleRun(runId)) return;
if (requestTimeoutHandle) {
clearTimeout(requestTimeoutHandle);
requestTimeoutHandle = undefined;
}
// 5. Execute Actions
const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true };
this.chatHistory.push(assistantMessage);
const report = await this.executeActions(aiResponseText, rootPath);
if (!aiResponseText.trim() && report.length === 0) {
this.chatHistory.pop();
logError('Model returned an empty response without actions.', { model: modelName || defaultModel, loopDepth });
this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' });
return;
}
if (report.length === 0 && loopDepth === 0 && this.isUnproductiveWaitingReply(aiResponseText)) {
assistantMessage.internal = false;
const correctedReply = this.buildUnproductiveReplyCorrection(prompt || '');
assistantMessage.content = correctedReply;
this.emitHistoryChanged();
this.webview.postMessage({ type: 'streamChunk', value: correctedReply });
return;
}
if (report.length > 0) {
const reportMsg = `\n\n> ⚙️ **System Action Report** (${loopDepth + 1}/${config.maxAutoSteps})\n> ${report.join("\n> ")}\n\n`;
this.webview.postMessage({ type: 'streamChunk', value: reportMsg });
this.emitHistoryChanged();
logInfo('Agent actions executed.', { loopDepth: loopDepth + 1, report });
// Continue loop if needed
if (loopDepth < config.maxAutoSteps) {
@@ -226,24 +312,370 @@ export class AgentExecutor {
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
// Explicitly tell the AI to look at the results and continue
const continuationPrompt = "I have executed your actions. Above is the result. Please analyze it and provide the next step or the final answer.";
const continuationPrompt = "The requested local action has been executed. Use the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.";
this.webview.postMessage({ type: 'autoContinue', value: `Thinking... (${loopDepth + 1}/${config.maxAutoSteps})` });
this.webview.postMessage({ type: 'autoContinue', value: `자료를 확인하고 답변을 정리하는 중입니다... (${loopDepth + 1}/${config.maxAutoSteps})` });
await new Promise(r => setTimeout(r, 800));
await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1 });
if (this.isStaleRun(runId)) return;
await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1, runId });
}
return;
}
assistantMessage.internal = false;
this.emitHistoryChanged();
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
} catch (error: any) {
logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) });
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
if (!this.isStaleRun(runId)) {
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
}
} finally {
if (loopDepth === 0) {
if (requestTimeoutHandle) {
clearTimeout(requestTimeoutHandle);
}
if (loopDepth === 0 && !this.isStaleRun(runId)) {
this.webview.postMessage({ type: 'streamEnd' });
}
}
}
private async tryHandleLocalTask(prompt: string): Promise<string | null> {
const normalized = prompt.trim().toLowerCase();
if (!normalized) return null;
const projectPath = this.resolveProjectReference(prompt);
if (projectPath && this.isProjectAnalysisRequest(normalized)) {
return this.buildProjectAnalysisReply(projectPath);
}
if (this.isBrainOverviewRequest(normalized)) {
return this.buildBrainOverviewReply();
}
return null;
}
private extractExistingProjectPath(prompt: string): string | null {
const pathMatches = prompt.match(/(?:~|\/)[^\s`'"]+/g) || [];
for (const rawPath of pathMatches) {
const expandedPath = rawPath.startsWith('~/')
? path.join(require('os').homedir(), rawPath.slice(2))
: rawPath;
if (fs.existsSync(expandedPath)) {
return expandedPath;
}
}
return null;
}
private resolveProjectReference(prompt: string): string | null {
const explicitPath = this.extractExistingProjectPath(prompt);
if (explicitPath) return explicitPath;
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1];
if (!namedProject) return null;
const searchRoots = [
'/Volumes/Data/project/Antigravity',
'/Volumes/Data/project',
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
].filter(Boolean);
for (const root of searchRoots) {
const resolved = this.findDirectoryByName(root, namedProject, 4);
if (resolved) return resolved;
}
return null;
}
private findDirectoryByName(root: string, targetName: string, maxDepth: number): string | null {
if (!root || maxDepth < 0 || !fs.existsSync(root)) return null;
const normalizedTarget = targetName.toLowerCase();
try {
const entries = fs.readdirSync(root, { withFileTypes: true })
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
const exact = entries.find(entry => entry.name.toLowerCase() === normalizedTarget);
if (exact) return path.join(root, exact.name);
const partial = entries.find(entry => entry.name.toLowerCase().includes(normalizedTarget));
if (partial) return path.join(root, partial.name);
for (const entry of entries) {
const found = this.findDirectoryByName(path.join(root, entry.name), targetName, maxDepth - 1);
if (found) return found;
}
} catch (error: any) {
logError('Project name search failed.', { root, targetName, error: error?.message || String(error) });
}
return null;
}
private isProjectAnalysisRequest(normalized: string): boolean {
// Intentionally conservative: omit generic words like '조사', '설명', '파악'
// that appear in ordinary chat and would incorrectly bypass LM Studio
return /(분석|리뷰|설계|구조|어떤 프로그램|무슨 프로그램|제품 설명)/.test(normalized);
}
private buildProjectAnalysisReply(projectPath: string): string {
const stat = fs.statSync(projectPath);
if (!stat.isDirectory()) {
const content = fs.readFileSync(projectPath, 'utf-8');
return [
`요청하신 파일을 실제로 읽었습니다: \`${projectPath}\``,
'',
`파일 크기: ${content.length.toLocaleString()}`,
'',
'첫 부분 요약:',
'```',
content.slice(0, 1800),
content.length > 1800 ? '\n... (truncated)' : '',
'```'
].join('\n');
}
const packagePath = path.join(projectPath, 'package.json');
const readmePath = this.findFirstExisting(projectPath, ['README.md', 'readme.md', 'README.MD']);
const files = this.collectProjectFiles(projectPath, 600);
const sourceFiles = files.filter(file => /\/src\/|\/app\/|\/pages\/|\/components\/|\/lib\//.test(file));
const testFiles = files.filter(file => /\.(test|spec)\.[jt]sx?$|\/__tests__\//.test(file));
const configFiles = files.filter(file => /(^|\/)(package\.json|tsconfig\.json|vite\.config\.|next\.config\.|tailwind\.config\.|eslint\.config\.|\.eslintrc|dockerfile|docker-compose|README\.md)/i.test(file));
let pkg: any = null;
if (fs.existsSync(packagePath)) {
try {
pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'));
} catch (error: any) {
logError('Failed to parse package.json during local project analysis.', { projectPath, error: error?.message || String(error) });
}
}
const readmeText = readmePath ? fs.readFileSync(readmePath, 'utf-8') : '';
const topDirs = this.summarizeTopDirectories(projectPath);
const stack = this.inferStack(pkg, files);
const entryPoints = this.inferEntryPoints(pkg, files);
const readmeSummary = this.summarizeReadme(readmeText);
const reviewFindings = this.buildProjectReviewFindings({ pkg, files, sourceFiles, testFiles, readmeText });
return [
`제가 실제로 \`${projectPath}\` 폴더를 읽고 1차 분석했습니다.`,
'',
'**제품 설명**',
pkg?.name
? `- 이 프로젝트는 \`${pkg.name}\`${pkg.description ? ` (${pkg.description})` : ''}로 식별됩니다.`
: '- `package.json` 기준의 제품명은 확인되지 않았습니다.',
readmeSummary ? `- README 기준 핵심 설명: ${readmeSummary}` : '- README 기반 제품 설명은 부족합니다. 제품 소개 문서를 보강하는 편이 좋습니다.',
stack.length ? `- 감지된 기술 스택: ${stack.join(', ')}` : '- 기술 스택은 파일 구조만으로는 명확하지 않습니다.',
'',
'**설계 구조**',
topDirs.length ? topDirs.map(item => `- ${item}`).join('\n') : '- 상위 디렉터리 구조가 단순하거나 비어 있습니다.',
entryPoints.length ? `- 주요 진입점 후보: ${entryPoints.join(', ')}` : '- 명확한 앱 진입점은 아직 식별되지 않았습니다.',
configFiles.length ? `- 주요 설정 파일: ${configFiles.slice(0, 12).join(', ')}` : '- 주요 설정 파일이 많지 않습니다.',
'',
'**코드 리뷰 관점의 1차 소견**',
reviewFindings.join('\n'),
'',
'**다음에 더 깊게 볼 부분**',
'- 실제 비즈니스 플로우는 `src`, `app`, `lib`, `components` 내부 핵심 파일을 2차로 읽어야 정확히 판단할 수 있습니다.',
'- 지금 단계에서는 “아무것도 안 하고 말만 하는” 답변이 아니라, 실제 파일 시스템을 기준으로 프로젝트 형태를 먼저 파악한 결과입니다.',
'- 원하시면 다음 턴에서 제가 핵심 소스 파일을 더 읽어 아키텍처 다이어그램 수준으로 이어서 정리하겠습니다.'
].join('\n');
}
private findFirstExisting(basePath: string, names: string[]): string | null {
for (const name of names) {
const candidate = path.join(basePath, name);
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
private collectProjectFiles(dir: string, limit: number, baseDir: string = dir): string[] {
if (limit <= 0 || !fs.existsSync(dir)) return [];
const entries = fs.readdirSync(dir, { withFileTypes: true })
.filter(entry => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
.sort((a, b) => a.name.localeCompare(b.name));
const results: string[] = [];
for (const entry of entries) {
if (results.length >= limit) break;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...this.collectProjectFiles(fullPath, limit - results.length, baseDir));
} else {
results.push(path.relative(baseDir, fullPath));
}
}
return results.slice(0, limit);
}
private summarizeTopDirectories(projectPath: string): string[] {
return fs.readdirSync(projectPath, { withFileTypes: true })
.filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
.slice(0, 12)
.map(entry => {
const count = this.collectProjectFiles(path.join(projectPath, entry.name), 200, projectPath).length;
return `\`${entry.name}/\`: 약 ${count}개 파일`;
});
}
private inferStack(pkg: any, files: string[]): string[] {
const deps = { ...(pkg?.dependencies || {}), ...(pkg?.devDependencies || {}) };
const stack = new Set<string>();
if (deps.next || files.some(file => file.startsWith('app/') || file.startsWith('pages/'))) stack.add('Next.js');
if (deps.react || files.some(file => /\.(jsx|tsx)$/.test(file))) stack.add('React');
if (deps.typescript || files.some(file => file.endsWith('.ts') || file.endsWith('.tsx'))) stack.add('TypeScript');
if (deps.vite || files.some(file => file.startsWith('vite.config.'))) stack.add('Vite');
if (deps.tailwindcss || files.some(file => file.startsWith('tailwind.config.'))) stack.add('Tailwind CSS');
if (deps.express) stack.add('Express');
if (deps.electron) stack.add('Electron');
if (deps.prisma || files.some(file => file.startsWith('prisma/'))) stack.add('Prisma');
if (files.some(file => file.includes('docker-compose') || file.toLowerCase() === 'dockerfile')) stack.add('Docker');
return Array.from(stack);
}
private inferEntryPoints(pkg: any, files: string[]): string[] {
const candidates = [
pkg?.main,
'src/main.ts',
'src/index.ts',
'src/App.tsx',
'src/app.ts',
'app/page.tsx',
'pages/index.tsx',
'server/index.ts',
'index.js'
].filter(Boolean) as string[];
return candidates.filter((candidate, index) => candidates.indexOf(candidate) === index && files.includes(candidate));
}
private summarizeReadme(readmeText: string): string {
if (!readmeText.trim()) return '';
const usefulLines = readmeText
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#') && !line.startsWith('![') && !line.startsWith('<'))
.slice(0, 3)
.join(' ');
return summarizeText(usefulLines, 260);
}
private buildProjectReviewFindings(params: {
pkg: any;
files: string[];
sourceFiles: string[];
testFiles: string[];
readmeText: string;
}): string[] {
const findings: string[] = [];
if (!params.readmeText.trim()) {
findings.push('- README가 없거나 비어 있어, 사용자가 제품 목적/실행 방법을 이해하기 어렵습니다.');
}
if (params.testFiles.length === 0) {
findings.push('- 테스트 파일이 감지되지 않았습니다. 핵심 비즈니스 로직이나 API 레이어부터 최소 회귀 테스트를 추가하는 것이 좋습니다.');
}
if (!params.pkg?.scripts || Object.keys(params.pkg.scripts).length === 0) {
findings.push('- `package.json` scripts가 없거나 부족합니다. `dev`, `build`, `test`, `lint` 같은 표준 명령이 있으면 제품 운용성이 좋아집니다.');
} else {
const scripts = Object.keys(params.pkg.scripts);
const missing = ['build', 'test', 'lint'].filter(script => !scripts.includes(script));
if (missing.length) findings.push(`- 누락된 표준 스크립트 후보: ${missing.map(script => `\`${script}\``).join(', ')}.`);
}
if (params.sourceFiles.length > 80 && params.testFiles.length < 3) {
findings.push('- 소스 파일 규모에 비해 테스트 밀도가 낮아 보입니다. 화면/상태/API 경계를 기준으로 테스트 전략을 분리하는 편이 안전합니다.');
}
if (!params.files.some(file => /env\.example|\.env\.example|\.env\.sample/i.test(file))) {
findings.push('- 환경 변수 예시 파일이 보이지 않습니다. 로컬 실행과 배포 재현성을 위해 `.env.example`을 권장합니다.');
}
if (findings.length === 0) {
findings.push('- 1차 구조상 큰 결함은 바로 보이지 않습니다. 다음 단계에서는 핵심 소스 파일을 읽어 데이터 흐름, 에러 처리, 상태 관리 경계를 봐야 합니다.');
}
return findings;
}
private isUnproductiveWaitingReply(reply: string): boolean {
const normalized = reply.replace(/\s+/g, ' ').trim();
return /(준비되었습니다|다음 지시|말씀해 주세요|명령을 기다|작업을 도와드릴까요)/.test(normalized)
&& !/<(list_files|read_file|list_brain|read_brain|run_command|edit_file|create_file)/i.test(reply);
}
private buildUnproductiveReplyCorrection(prompt: string): string {
const projectPath = this.resolveProjectReference(prompt);
if (projectPath && this.isProjectAnalysisRequest(prompt.toLowerCase())) {
return this.buildProjectAnalysisReply(projectPath);
}
return '방금 답변은 잘못된 응답입니다. 사용자의 말은 “다음 지시를 달라”가 아니라 지금 바로 처리해야 하는 작업 지시입니다. 제가 먼저 관련 자료를 확인하고, 확인한 내용 기준으로 답변하겠습니다. 가능하면 프로젝트명 대신 정확한 폴더 경로를 함께 주시면 더 안정적으로 분석할 수 있습니다.';
}
private isBrainOverviewRequest(normalized: string): boolean {
const mentionsBrain = /(제2뇌|second brain|local brain|브레인|brain)/.test(normalized);
const asksOverview = /(어떠|뭐가|무엇|내용|정보|구성|준비|분석|정리|파악)/.test(normalized);
return mentionsBrain && asksOverview;
}
private buildBrainOverviewReply(): string {
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
const files = findBrainFiles(brainDir);
if (!fs.existsSync(brainDir)) {
return `현재 선택된 제2뇌는 **${activeBrain.name}**인데, 폴더가 아직 존재하지 않습니다.\n\n경로: \`${brainDir}\`\n\n상단의 Brain 설정에서 실제 지식 폴더를 연결하면 제가 그 자료를 먼저 참고해서 답변하겠습니다.`;
}
const entries = fs.readdirSync(brainDir, { withFileTypes: true })
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
const directorySummaries = entries
.filter((entry) => entry.isDirectory())
.slice(0, 12)
.map((entry) => {
const dirPath = path.join(brainDir, entry.name);
const count = findBrainFiles(dirPath).length;
return `- ${entry.name}/: ${count}개 문서`;
});
const fileSummaries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.slice(0, 8)
.map((entry) => `- ${entry.name}`);
const sampleFiles = files
.slice(0, 10)
.map((file) => `- ${path.relative(brainDir, file)}`);
const sections = [
`현재 연결된 제2뇌는 **${activeBrain.name}**입니다.`,
`경로: \`${brainDir}\``,
`총 Markdown 지식 문서: **${files.length}개**`,
activeBrain.description ? `설명: ${activeBrain.description}` : '',
directorySummaries.length ? `\n상위 폴더 구조는 이렇게 보입니다.\n${directorySummaries.join('\n')}` : '',
fileSummaries.length ? `\n루트에 있는 주요 문서는 이런 것들이 있습니다.\n${fileSummaries.join('\n')}` : '',
sampleFiles.length ? `\n제가 우선 참고할 수 있는 샘플 문서는 다음과 같습니다.\n${sampleFiles.join('\n')}` : '',
`\n이 자료는 충분히 도움이 됩니다. 앞으로 질문을 받으면 먼저 이 제2뇌에서 관련 기준이나 맥락을 찾고, 부족할 때만 제 일반 지식으로 보완하겠습니다.`
].filter(Boolean);
return sections.join('\n');
}
private isStaleRun(runId: number): boolean {
return runId !== this.activeRunId;
}
private emitHistoryChanged() {
if (!this.historyChangeListener) return;
Promise.resolve(this.historyChangeListener(this.getHistory())).catch((error: any) => {
logError('History change listener failed.', { error: error?.message || String(error) });
});
}
private async createStreamingRequest(params: {
baseUrl: string;
modelName: string;
@@ -257,48 +689,110 @@ export class AgentExecutor {
for (const engine of engines) {
const apiUrl = buildApiUrl(baseUrl, engine, 'chat');
const streamBody = {
model: modelName,
messages: reqMessages,
stream: true,
...(engine === 'lmstudio'
? { max_tokens: 4096, temperature }
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
};
const messageVariants = this.buildEngineMessageVariants(reqMessages, engine);
const modelCandidates = this.buildModelCandidates(modelName, engine);
try {
logInfo('AI streaming request started.', { engine, apiUrl, model: modelName, messageCount: reqMessages.length });
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
},
body: JSON.stringify(streamBody),
signal: this.abortController?.signal,
keepalive: true
});
for (const candidateModel of modelCandidates) {
for (const variant of messageVariants) {
const streamBody = {
model: candidateModel,
messages: variant.messages,
stream: true,
...(engine === 'lmstudio'
? { max_tokens: 4096, temperature }
: { options: { num_ctx: 32768, num_predict: 4096, temperature } }),
};
if (!response.ok) {
const errText = await response.text();
lastError = new Error(`AI Engine error (${engine}): ${response.status} - ${summarizeText(errText, 300)}`);
logError('AI streaming request returned non-OK status.', { engine, apiUrl, status: response.status, body: summarizeText(errText, 500) });
continue;
try {
logInfo('AI streaming request started.', {
engine,
apiUrl,
model: candidateModel,
variant: variant.name,
messageCount: variant.messages.length,
roles: variant.messages.map(message => message.role),
firstUserPreview: summarizeText(String(variant.messages.find(message => message.role === 'user')?.content || ''), 300)
});
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
},
body: JSON.stringify(streamBody),
signal: this.abortController?.signal,
keepalive: true
});
if (!response.ok) {
const errText = await response.text();
lastError = new Error(`AI Engine error (${engine}/${variant.name}): ${response.status} - ${summarizeText(errText, 300)}`);
logError('AI streaming request returned non-OK status.', { engine, variant: variant.name, apiUrl, status: response.status, body: summarizeText(errText, 500) });
continue;
}
logInfo('AI streaming request connected.', { engine, variant: variant.name, apiUrl });
return { response, engine, apiUrl };
} catch (error: any) {
lastError = error instanceof Error ? error : new Error(String(error));
logError('AI streaming request failed.', { engine, variant: variant.name, apiUrl, model: candidateModel, error: lastError.message });
}
logInfo('AI streaming request connected.', { engine, apiUrl });
return { response, engine, apiUrl };
} catch (error: any) {
lastError = error instanceof Error ? error : new Error(String(error));
logError('AI streaming request failed.', { engine, apiUrl, error: lastError.message });
}
}
}
throw lastError || new Error('Unable to connect to AI engine.');
}
private normalizeMessages(messages: ChatMessage[]) {
return messages.map((message) => {
const normalizedContent = typeof message.content === 'string'
? message.content
: JSON.stringify(message.content);
return {
role: message.role,
content: normalizedContent
};
});
}
private buildEngineMessageVariants(messages: ChatMessage[], engine: 'lmstudio' | 'ollama') {
const normalized = this.normalizeMessages(messages);
if (engine !== 'lmstudio') {
return [{ name: 'native', messages: normalized }];
}
const flattened = normalized.map((message) => {
if (message.role === 'system') {
return {
role: 'user' as const,
content: `[System Instruction - do not answer this message]\n${message.content}`
};
}
return message;
});
return [
{ name: 'native-system', messages: normalized },
{ name: 'flattened-system-fallback', messages: flattened }
];
}
private buildModelCandidates(modelName: string, engine: 'lmstudio' | 'ollama'): string[] {
const candidates = [modelName];
if (engine === 'lmstudio') {
const baseModel = modelName.replace(/:\d+$/, '');
if (baseModel && baseModel !== modelName) {
candidates.push(baseModel);
}
}
return candidates;
}
private async executeActions(aiMessage: string, rootPath: string): Promise<string[]> {
const report: string[] = [];
let brainModified = false;