import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; // axios removed import { findBrainFiles, getSystemPrompt, shouldAutoPushBrain, buildApiUrl, getActiveBrainProfile, logError, logInfo, resolveEngine, summarizeText } from './utils'; import { BrainProfile, getConfig, EXCLUDED_DIRS } from './config'; import { validatePath, sanitizeCommand } from './security'; import { TransactionManager } from './core/transaction'; import { SessionManager } from './core/session'; import { PlannerAgent, ResearcherAgent, WriterAgent } from './agents/factory'; import { AgentWorkflowManager } from './agents/AgentWorkflowManager'; import { ErrorTranslator } from './core/errorHandler'; import { agentEvents, AgentEventTypes } from './core/events'; import { AgentExecutionError, FileSystemError, APICommunicationError } from './core/errors'; import { StatusBarManager, AgentStatus } from './core/statusBar'; import { lockManager } from './core/lock'; import { actionQueue } from './core/queue'; import { ConflictResolver } from './core/conflict'; import { buildSecondBrainTrace, enforceProjectClaimPolicyInAnswer, renderSecondBrainTraceContext, renderSecondBrainTraceMarkdown, SecondBrainTrace } from './features/secondBrainTrace'; export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; internal?: boolean; rationale?: { problem: string; goal: string; reasoning: string; }; } type HistoryChangeListener = (history: ChatMessage[]) => void | Promise; // --- Agent Roles & Workflows --- export type AgentRole = 'planner' | 'researcher' | 'writer'; type LocalProjectIntent = 'review-evaluation' | 'knowledge-creation' | 'implementation' | 'documentation' | 'thinking' | 'general'; const AGENT_PROMPTS: Record = { planner: `You are the [Planner Agent]. Your goal is to analyze the user's request and create a detailed execution plan. 1. Breakdown the request into logical steps. 2. Identify key search keywords for the knowledge base. 3. Output your plan in a structured format using tags.`, researcher: `You are the [Researcher Agent]. Your goal is to gather and analyze data based on the Planner's strategy. 1. Search the local knowledge base using the provided keywords. 2. Evaluate data reliability and extract relevant facts. 3. Output your findings using tags.`, writer: `You are the [Writer Agent]. Your goal is to synthesize all gathered information into a high-quality final report. 1. Use the data from the Researcher. 2. Follow the project's visual and tone-of-voice guidelines. 3. Deliver a logical, consistent, and polished response.` }; 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; private transactionManager: TransactionManager; private sessionManager: SessionManager; private statusBarManager: StatusBarManager; private currentTaskId: string = 'default_session'; constructor( private context: vscode.ExtensionContext ) { this.transactionManager = new TransactionManager(); this.sessionManager = new SessionManager(this.context); this.statusBarManager = new StatusBarManager(); this.restoreLastSession(); } private parseRationale(text: string) { const match = text.match(/([\s\S]*?)<\/rationale>/); if (!match) return undefined; const raw = match[1]; const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || ""; const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || ""; const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim(); return { problem, goal, reasoning }; } private sanitizeAssistantContent(text: string): string { return text .replace(/[\s\S]*?<\/rationale>/gi, '') .replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '') .replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '') .trim(); } private async restoreLastSession() { try { const lastSession = this.sessionManager.loadLastActiveSession(); if (lastSession) { this.chatHistory = lastSession.history; this.currentTaskId = lastSession.taskId; logInfo(`Restored last session: ${this.currentTaskId}`); } } catch (error) { logError('Failed to restore last session. Starting fresh.', error); } } public setWebview(webview: vscode.Webview) { this.webview = webview; } public setHistoryChangeListener(listener: HistoryChangeListener) { this.historyChangeListener = listener; } public getHistory() { return this.chatHistory.filter(message => !message.internal || message.role === 'assistant'); } 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 approveTransaction() { if (!this.transactionManager.isActive()) return; this.transactionManager.commit(); agentEvents.emit(AgentEventTypes.TRANSACTION_COMMITTED); this.statusBarManager.updateStatus(AgentStatus.Success, 'Changes committed.'); this.webview?.postMessage({ type: 'streamChunk', value: '\n✅ **작업이 승인되어 반영되었습니다.**' }); } public async rejectTransaction() { if (!this.transactionManager.isActive()) return; this.transactionManager.rollback(); agentEvents.emit(AgentEventTypes.TRANSACTION_ROLLED_BACK); this.statusBarManager.updateStatus(AgentStatus.Idle, 'Changes rolled back.'); this.webview?.postMessage({ type: 'streamChunk', value: '\n❌ **작업이 거부되어 모든 변경사항이 취소되었습니다.**' }); } public async handlePrompt( prompt: string | null, modelName: string, options: { internetEnabled?: boolean, brainEnabled?: boolean, loopDepth?: number, visionContent?: any[], temperature?: number, systemPrompt?: string, runId?: number, agentSkillContext?: string, negativePrompt?: string, designerContext?: string, secondBrainTraceEnabled?: boolean, secondBrainTraceDebug?: boolean, brainProfileId?: string } ) { const { internetEnabled = false, brainEnabled = false, loopDepth = 0, visionContent, temperature = 0.7, systemPrompt = getSystemPrompt() } = options; const { ollamaUrl, defaultModel: configDefaultModel, timeout, multiAgentEnabled } = getConfig(); const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId); // Decide whether to use Multi-Agent Workflow as an internal execution strategy. if (loopDepth === 0 && this.shouldUseMultiAgentWorkflow(prompt || '', multiAgentEnabled)) { return this.executeMultiAgentWorkflow(prompt!, modelName, options); } const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent; let requestTimeoutHandle: ReturnType | undefined; if (!this.webview) return; try { // 0. Safety Check: Rollback any dangling transaction from previous runs if (this.transactionManager.isActive()) { logInfo('Cleaning up dangling transaction from previous session.'); this.transactionManager.rollback(); } this.statusBarManager.updateStatus(AgentStatus.Thinking); if (loopDepth === 0) { if (this.abortController) { this.abortController.abort(); this.abortController = null; } this.activeRunId = runId; this.currentTaskId = `task_${Date.now()}`; await this.context.workspaceState.update('lastActionStr', undefined); } // 1. Prepare Context const workspaceFolders = vscode.workspace.workspaceFolders; const rootPath = workspaceFolders ? workspaceFolders[0].uri.fsPath : ''; let contextBlock = ''; const config = getConfig(); const activeBrain = options.brainProfileId ? (config.brainProfiles.find((profile) => profile.id === options.brainProfileId) || getActiveBrainProfile()) : getActiveBrainProfile(); const brainFiles = findBrainFiles(activeBrain.localBrainPath); let secondBrainTrace: SecondBrainTrace | null = null; if (options.secondBrainTraceEnabled && prompt && loopDepth === 0) { secondBrainTrace = buildSecondBrainTrace(prompt, activeBrain.localBrainPath, { force: this.isExplicitSecondBrainRequest(prompt), limit: Math.max(config.memoryLongTermFiles, 5) }); } 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 brainInventoryCtx = prompt && this.isSecondBrainInventoryRequest(prompt) ? `\n\n${this.buildSecondBrainInventoryContext(activeBrain, brainFiles)}` : ''; const editor = vscode.window.activeTextEditor; if (editor && editor.document.uri.scheme === 'file') { const text = editor.document.getText(); const name = path.basename(editor.document.fileName); if (text.trim().length > 0 && text.length < config.maxContextSize) { contextBlock = `\n\n[Currently open file: ${name}]\n\`\`\`\n${text}\n\`\`\``; } } const localPathContext = prompt && loopDepth === 0 ? this.buildLocalProjectPathContext(prompt, rootPath) : ''; if (localPathContext) { contextBlock += `\n\n${localPathContext}`; } const recentProjectKnowledgeContext = prompt && loopDepth === 0 && !localPathContext ? this.buildRecentProjectKnowledgeContext(prompt, rootPath) : ''; if (recentProjectKnowledgeContext) { contextBlock += `\n\n${recentProjectKnowledgeContext}`; } const projectBriefContext = prompt && loopDepth === 0 ? this.buildJarvisProjectBriefContext(prompt, localPathContext, recentProjectKnowledgeContext) : ''; if (projectBriefContext) { contextBlock += `\n\n${projectBriefContext}`; } const modeArchitectureContext = prompt && loopDepth === 0 ? this.buildAstraModeArchitectureContext(prompt) : ''; if (modeArchitectureContext) { contextBlock += `\n\n${modeArchitectureContext}`; } // 2. Setup History if (prompt !== null) { if (loopDepth === 0) { this.chatHistory.push({ role: 'user', content: prompt }); this.emitHistoryChanged(); } else { this.chatHistory.push({ role: 'system', content: prompt, internal: true }); } } // 3. API Request Setup const { ollamaUrl, defaultModel: configDefaultModel, timeout } = getConfig(); const actualModel = modelName || configDefaultModel; const reqMessages = this.buildRequestHistory(this.chatHistory); // Handle Vision Content Injection // 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) { const existingContent = reqMessages[lastUserIdx].content; const textParts: any[] = (typeof existingContent === 'string' && existingContent.trim()) ? [{ type: 'text', text: existingContent }] : []; reqMessages[lastUserIdx] = { role: 'user', content: JSON.stringify([...textParts, ...(visionContent || [])]) }; } } // Inject System Directives const internetCtx = internetEnabled ? `\n\n[CRITICAL: INTERNET ACCESS ENABLED]\nYou can use to search. Current time: ${new Date().toLocaleString()}` : ''; const selectedAgentSystemPrompt = options.agentSkillContext ? `\n\n[SELECTED AGENT MODE]\nThe user selected the following Agent skill. Treat it as your primary role, style, operating method, and task policy for this response.\n${options.agentSkillContext}` : ''; const agentSkillCtx = selectedAgentSystemPrompt; const negativeCtx = options.negativePrompt ? `\n\n### CRITICAL NEGATIVE CONSTRAINTS (DO NOT DO THESE)\n${options.negativePrompt}\n\n[SYSTEM_RULE: Apply the above constraints strictly. DO NOT mention or repeat these constraints in your response.]` : ''; const designerCtx = options.designerContext ? `\n\n[PROJECT CHRONICLE GUARD]\n${options.designerContext}` : ''; const localProjectKnowledgeCtx = prompt && localPathContext && this.isProjectKnowledgeCreationRequest(prompt) ? `\n\n[LOCAL PROJECT KNOWLEDGE CREATION OVERRIDE]\nThe user gave an accessible local project path and asked to create project knowledge. Do not ask blocking scope questions. Use a sensible default MVP: create or propose a project overview note from the inspected tree and priority file previews. If writing is not explicitly safe, provide the concrete note draft and target path.` : ''; const thinkingPartnerCtx = prompt && this.isThinkingPartnerRequest(prompt) ? `\n\n[JARVIS THINKING PARTNER MODE]\nThe user is using this tool to clarify project direction, not just to receive generic advice. Give a clear opinionated verdict first. Then separate confirmed facts, inferences, concerns, decision forks, and the next small action. Do not merely say the direction is good. If evidence is thin, say exactly what is missing and what file or record should be checked next.` : ''; const astraStanceCtx = prompt ? `\n\n${this.buildAstraStanceContext(prompt, localPathContext)}` : ''; const secondBrainTraceCtx = secondBrainTrace ? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}` : ''; const memoryCtx = this.buildMemoryContext(prompt || '', activeBrain); const fullSystemPrompt = `${agentSkillCtx}\n\n${systemPrompt}${internetCtx}${memoryCtx}${designerCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}\n\n[CONTEXT]\n${brainContext}${brainInventoryCtx}\n${contextBlock}${negativeCtx}`; const messagesForRequest: ChatMessage[] = [ { role: 'system', content: fullSystemPrompt, internal: true }, ...reqMessages ]; // 4. Call AI Engine this.abortController = new AbortController(); requestTimeoutHandle = setTimeout(() => { logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth }); this.abortController?.abort(); }, timeout); const request = await this.createStreamingRequest({ baseUrl: ollamaUrl, modelName: actualModel, reqMessages: messagesForRequest, temperature }); const { response, engine, apiUrl } = request; if (this.isStaleRun(runId)) return; let aiResponseText = ''; const reader = response.body?.getReader(); if (!reader) throw new Error("Response body is not readable."); if (loopDepth === 0) this.webview.postMessage({ type: 'streamStart' }); let buffer = ''; const decoder = new TextDecoder(); try { 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'); buffer = lines.pop() || ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed === 'data: [DONE]') continue; try { const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; const json = JSON.parse(raw); const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; if (token) { aiResponseText += token; } } catch (e: any) { logError('Failed to parse streaming chunk.', { engine, apiUrl, chunk: summarizeText(trimmed, 300), error: e?.message || String(e) }); } } } } catch (err: any) { if (err.name === 'AbortError') { logInfo('Generation aborted by user.'); } else { logError('Stream reading error.', { engine, apiUrl, error: err?.message || String(err) }); this.webview?.postMessage({ type: 'error', value: `Connection lost: ${err.message}` }); } } // Final buffer processing if (buffer.trim() && buffer.trim() !== 'data: [DONE]') { try { const trimmed = buffer.trim(); const raw = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed; const json = JSON.parse(raw); const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || ''; if (token) { aiResponseText += token; } } catch (e: any) { logError('Failed to parse final streaming buffer.', { engine, apiUrl, buffer: summarizeText(buffer, 300), error: e?.message || String(e) }); } } if (this.isStaleRun(runId)) return; if (requestTimeoutHandle) { clearTimeout(requestTimeoutHandle); requestTimeoutHandle = undefined; } // 5. Execute Actions const rationale = this.parseRationale(aiResponseText); let assistantContent = this.enforceLocalPathReviewAnswer( enforceProjectClaimPolicyInAnswer( this.sanitizeAssistantContent(aiResponseText), secondBrainTrace ), localPathContext ); if (prompt && this.isSecondBrainInventoryRequest(prompt) && brainFiles.length > 0 && this.isNoBrainDataRefusal(assistantContent)) { assistantContent = this.buildSecondBrainInventoryFallbackAnswer(activeBrain, brainFiles, secondBrainTrace); } if (prompt && localPathContext && this.isProjectReviewEvaluationRequest(prompt) && this.isMisroutedProjectKnowledgeAnswer(assistantContent)) { assistantContent = this.buildProjectReviewFallbackAnswer(localPathContext); } if (prompt && localPathContext && this.isProjectReviewEvaluationRequest(prompt) && this.isShallowProjectReviewAnswer(assistantContent)) { assistantContent = this.buildProjectReviewFallbackAnswer(localPathContext); } if (prompt && localPathContext && this.isProjectKnowledgeCreationRequest(prompt)) { const record = this.writeProjectKnowledgeRecord(localPathContext); if (this.isBlockingProjectKnowledgeAnswer(assistantContent)) { assistantContent = this.buildProjectKnowledgeFallbackAnswer(localPathContext, record); } else if (record && !assistantContent.includes(record.filePath)) { assistantContent = [ assistantContent, '', '## 생성된 기록', `프로젝트 지식 기록을 생성했습니다: \`${record.filePath}\`` ].join('\n'); } } if (prompt && recentProjectKnowledgeContext) { assistantContent = this.ensureRecentProjectKnowledgeEvidence(assistantContent, recentProjectKnowledgeContext); } if (prompt && localPathContext) { assistantContent = this.ensureLocalProjectPathEvidence(assistantContent, localPathContext); } if (prompt) { assistantContent = this.applyAstraQualityGate(assistantContent, prompt, localPathContext); } const traceMarkdown = secondBrainTrace ? renderSecondBrainTraceMarkdown(secondBrainTrace, !!options.secondBrainTraceDebug) : ''; const finalAssistantContent = traceMarkdown ? `${assistantContent}\n${traceMarkdown}` : assistantContent; this.statusBarManager.updateStatus(AgentStatus.Executing); const report = await this.executeActions(aiResponseText, rootPath, activeBrain); if (!assistantContent.trim() && report.length === 0) { logError('Model returned an empty response without actions.', { model: actualModel, engine, apiUrl, loopDepth }); this.webview.postMessage({ type: 'error', value: [ 'AI engine returned an empty response.', `Engine: ${engine}`, `Model: ${actualModel}`, 'The request reached the local LLM server, but no usable content was returned. Try another model, restart the local server, or reduce the prompt/context size.' ].join('\n') }); return; } if (report.length > 0) { this.emitHistoryChanged(); logInfo('Agent actions executed.', { loopDepth: loopDepth + 1, report }); // Continue loop if needed if (loopDepth < config.maxAutoSteps) { const currentActionStr = report.join('|'); const lastActionStr = this.context.workspaceState.get('lastActionStr'); if (currentActionStr === lastActionStr) { this.webview.postMessage({ type: 'streamChunk', value: "\n⚠️ *Stopping to prevent infinite loop.*" }); return; } await this.context.workspaceState.update('lastActionStr', currentActionStr); logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report }); // Explicitly tell the AI to look at the results and continue 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: `자료를 확인하고 답변을 정리하는 중입니다... (${loopDepth + 1}/${config.maxAutoSteps})` }); await new Promise(r => setTimeout(r, 800)); if (this.isStaleRun(runId)) return; await this.handlePrompt(continuationPrompt, modelName, { ...options, loopDepth: loopDepth + 1, runId }); } return; } const assistantMessage: ChatMessage = { role: 'assistant', content: finalAssistantContent, internal: false, rationale }; this.chatHistory.push(assistantMessage); this.emitHistoryChanged(); this.statusBarManager.updateStatus(AgentStatus.Success); this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent }); } catch (error: any) { this.statusBarManager.updateStatus(AgentStatus.Error, error.message); logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) }); if (!this.isStaleRun(runId)) { this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` }); } } finally { if (requestTimeoutHandle) { clearTimeout(requestTimeoutHandle); } if (loopDepth === 0 && !this.isStaleRun(runId)) { this.webview.postMessage({ type: 'streamEnd' }); } } } public async executeMultiAgentWorkflow( prompt: string, modelName: string, options: any ) { if (!this.webview) return; this.stop(); this.abortController = new AbortController(); const signal = this.abortController.signal; this.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Running'); this.webview.postMessage({ type: 'streamStart' }); try { let brainContext = 'No specific context available'; try { const config = getConfig(); const activeBrain = options.brainProfileId ? (config.brainProfiles.find((profile) => profile.id === options.brainProfileId) || getActiveBrainProfile()) : getActiveBrainProfile(); const brainFiles = findBrainFiles(activeBrain.localBrainPath); brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`; } catch (ctxErr) { logError('Failed to load brain context for agents', ctxErr); } const selectedAgentContext = options.agentSkillContext ? `\nSelected Agent Reference:\n${options.agentSkillContext}` : ''; const designerContext = options.designerContext ? `\nProject Chronicle Guard:\n${options.designerContext}` : ''; // 워크플로우 매니저에게 설정 기반 실행 위임 const finalReport = await AgentWorkflowManager.runStrictWorkflow( prompt, modelName, `${brainContext}${selectedAgentContext}${designerContext}`, signal, (step, msg) => { this.webview?.postMessage({ type: 'autoContinue', value: `${step}: ${msg}` }); // 각 단계별 시작을 알림 this.webview?.postMessage({ type: 'streamChunk', value: `\n\n> **[${step}]** ${msg}\n\n` }); } ); if (signal.aborted || !this.webview) return; this.webview.postMessage({ type: 'streamChunk', value: `\n\n--- \n\n${finalReport}` }); this.webview.postMessage({ type: 'streamEnd' }); this.chatHistory.push({ role: 'assistant', content: finalReport }); this.emitHistoryChanged(); this.statusBarManager.updateStatus(AgentStatus.Success, 'Workflow Complete'); this.webview.postMessage({ type: 'autoContinue', value: '✅ 모든 분석이 성공적으로 완료되었습니다.' }); } catch (error: any) { if (error.name === 'AbortError' || error.message?.includes('cancelled')) { this.statusBarManager.updateStatus(AgentStatus.Idle, 'Workflow Cancelled'); return; } const friendly = ErrorTranslator.translate(error); logError('Workflow failed', error); this.webview.postMessage({ type: 'autoContinue', value: '' }); this.webview.postMessage({ type: 'error', value: `### ${friendly.title}\n\n**상태:** ${friendly.message}\n\n**해결 방법:** ${friendly.action}` }); this.statusBarManager.updateStatus(AgentStatus.Idle, 'Error occurred'); } } private async callAgent(role: AgentRole, prompt: string, modelName: string, options: any): Promise { const persona = AGENT_PROMPTS[role]; const { ollamaUrl, timeout } = getConfig(); const messages: ChatMessage[] = [ { role: 'system', content: persona }, { role: 'user', content: prompt } ]; const request = await this.createStreamingRequest({ baseUrl: ollamaUrl, modelName: modelName, reqMessages: messages, temperature: 0.3 // Use lower temperature for planning and research }); let responseText = ''; const reader = request.response.body?.getReader(); if (!reader) throw new Error("Agent response body is not readable."); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed === 'data: [DONE]') continue; try { const json = JSON.parse(trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed); const content = json.choices?.[0]?.delta?.content || json.message?.content || ''; responseText += content; } catch (e) {} } } return responseText; } private isExplicitSecondBrainRequest(prompt: string): boolean { return /(second brain|2nd brain|제2뇌|브레인|brain|기억|기록|노트|문서|참고해서|사용해서|검색해서|근거|출처)/i.test(prompt); } private isSecondBrainInventoryRequest(prompt: string): boolean { const normalized = prompt.toLowerCase(); const asksBrain = /(second brain|2nd brain|제2뇌|브레인|brain)/i.test(normalized); const asksOverview = /(평가|분석|강점|약점|부족|무엇을 할 수|활용|전체|연결된|현재|inside|overview|inventory|strength|weakness)/i.test(normalized); return asksBrain && asksOverview; } private buildSecondBrainInventoryContext(activeBrain: BrainProfile, brainFiles: string[]): string { const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file)); const directoryCounts = new Map(); for (const rel of relativeFiles) { const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)'; directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1); } const topDirectories = [...directoryCounts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 12) .map(([dir, count]) => `- ${dir}: ${count} markdown files`) .join('\n'); const samples = relativeFiles .slice(0, 40) .map((file) => `- ${file}`) .join('\n'); return [ '[SECOND BRAIN INVENTORY]', 'The user is asking about the currently selected Second Brain as a knowledge base. Use this inventory as direct evidence.', `Selected brain name: ${activeBrain.name}`, `Selected brain path: ${activeBrain.localBrainPath}`, `Markdown file count: ${brainFiles.length}`, brainFiles.length > 0 ? 'Do not say the Second Brain has no data, no files, or cannot be evaluated because files were not provided.' : 'No Markdown files were found in the selected Second Brain path.', topDirectories ? `Top-level distribution:\n${topDirectories}` : 'Top-level distribution: none', samples ? `Sample files:\n${samples}` : 'Sample files: none', 'For strengths and weaknesses, infer from the inventory and selected note excerpts. Mark broad conclusions as inference when they are not directly proven.' ].join('\n'); } private isNoBrainDataRefusal(answer: string): boolean { return /(분석할 만한 실제 데이터가 없어|분석할.*데이터가 없어|파일 목록.*제공|핵심 내용.*제공|자료를 준비|지식을 먼저 제공|cannot be evaluated|no data|no files)/i.test(answer); } private buildSecondBrainInventoryFallbackAnswer(activeBrain: BrainProfile, brainFiles: string[], trace: SecondBrainTrace | null): string { const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file)); const directoryCounts = new Map(); for (const rel of relativeFiles) { const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)'; directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1); } const topDirectories = [...directoryCounts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 8); const distribution = topDirectories .map(([dir, count]) => `- ${dir}: ${count}개`) .join('\n'); const selectedDocs = trace?.retrievedDocuments .filter((doc) => doc.selectedForAnswerContext) .map((doc) => `- ${doc.path} (${doc.sourceType}, score ${doc.score})`) .join('\n') || ''; return [ '## 간단 요약', `현재 선택된 제2뇌는 비어 있지 않습니다. \`${activeBrain.localBrainPath}\` 아래에서 Markdown 파일 ${brainFiles.length}개를 확인했기 때문에, 강점과 약점을 평가할 수 있습니다.`, '', '## 강점', '1. 지식량이 충분합니다. 수천 개 규모의 Markdown 노트가 있어 단일 프로젝트 메모장이 아니라 실제 지식 베이스로 볼 수 있습니다.', '2. 상위 폴더 기준으로 주제가 나뉘어 있어 검색과 확장에 유리합니다.', '3. AI, UX, 프로젝트 로그처럼 실행 지식과 참고 지식이 함께 있어 기획, 리서치, 의사결정 보조에 쓸 수 있습니다.', '4. Trace가 실제 문서를 찾고 있으므로 연결 자체는 동작합니다.', '', '## 약점', '1. 검색 결과에서 인덱스 문서와 일반 지식 문서가 상위에 올라옵니다. 제2뇌 전체 평가에는 도움이 되지만, 구체적 판단 근거로는 밀도가 낮습니다.', '2. Project Evidence와 General Knowledge가 명확히 분리되지 않아 답변이 조심스러워집니다.', '3. “강점/약점 평가” 같은 전체 분석 요청에는 단일 키워드 검색보다 폴더 분포, 대표 문서, 최근 문서, 프로젝트 로그를 함께 보는 전용 분석 흐름이 필요합니다.', '4. 문서 수가 많아서 요약 인덱스, 태그, source type 메타데이터가 약하면 좋은 문서가 검색 순위에서 밀릴 수 있습니다.', '', '## 확인된 분포', distribution || '- 상위 폴더 없음', '', selectedDocs ? '## 이번 검색에서 잡힌 문서\n' + selectedDocs : '', '', '## 활용 가능성', '이 제2뇌는 프로젝트 회고, UX/비즈니스 판단, 기술 리서치, 제안서 초안, 의사결정 근거 정리, 고객 요구사항 검토에 쓸 수 있습니다. 다음 개선 포인트는 “인덱스 문서보다 실제 근거 문서를 우선 선택하는 검색 랭킹”과 “프로젝트 근거 문서에 명시적 메타데이터를 붙이는 것”입니다.' ].filter(Boolean).join('\n'); } private isStaleRun(runId: number): boolean { return runId !== this.activeRunId; } private buildLocalProjectPathContext(prompt: string, rootPath: string): string { if (!this.shouldPreflightLocalProjectPath(prompt)) { return ''; } const candidates = this.extractLocalProjectPaths(prompt); if (candidates.length === 0) { return ''; } const intent = this.classifyLocalProjectIntent(prompt); const sections: string[] = [ '[LOCAL PROJECT PATH PREFLIGHT]', `Local project intent: ${intent}`, this.buildLocalProjectIntentGuidance(intent), 'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context before asking for uploads.', 'If access failed, explain the concrete failure. If access succeeded, proceed with code review from the scanned files.', 'If access succeeded and priority file previews are present, do not say that code was not provided.', 'Treat the Local project intent line as the routing decision for this response.', 'If intent is review-evaluation, do not create a project knowledge note. Review the inspected project as the primary task: strengths, weaknesses, risks, and extensibility.', 'If intent is knowledge-creation, answer that the project can be summarized from the inspected local path and propose or execute a project knowledge note based on the previews.', 'If intent is thinking, act as a project thinking partner and give a clear verdict grounded in the inspected files.' ]; for (const candidate of candidates.slice(0, 2)) { sections.push(this.inspectLocalProjectPath(candidate, rootPath)); } return sections.join('\n'); } private buildLocalProjectIntentGuidance(intent: LocalProjectIntent): string { switch (intent) { case 'review-evaluation': return [ 'Intent operating contract:', '- Review the project as a working product, not as a note to be generated.', '- Start with a sharp verdict: usable now, promising but risky, or not ready.', '- Use these review lenses in order: 1. purpose fit, 2. architecture shape, 3. data/control flow, 4. failure recovery, 5. operability/observability, 6. extensibility.', '- For each major claim, tie it to an observed file, folder, or missing evidence.', '- Name the strongest leverage point and the most dangerous blind spot.', '- End with a prioritized roadmap: stabilize first, then improve quality, then expand.' ].join('\n'); case 'knowledge-creation': return [ 'Intent operating contract:', '- Create a reusable project knowledge note from inspected evidence.', '- Do not ask for scope if the path is accessible; choose a small MVP overview by default.', '- Separate confirmed structure from inferred purpose and next deep-dive targets.' ].join('\n'); case 'implementation': return [ 'Intent operating contract:', '- Treat this as a change request, not advice.', '- Inspect the relevant files, make the smallest safe implementation, and verify it.', '- Preserve unrelated user changes.' ].join('\n'); case 'documentation': return [ 'Intent operating contract:', '- Produce or update documentation from inspected evidence.', '- Separate user-facing usage docs from internal architecture notes.', '- Avoid claiming behavior that is not visible in code or existing docs.' ].join('\n'); case 'thinking': return [ 'Intent operating contract:', '- Act as a thinking partner.', '- Give a direct opinion, then split confirmed facts, inferences, risks, decision forks, and one next move.', '- Avoid generic encouragement.' ].join('\n'); default: return [ 'Intent operating contract:', '- Use the inspected local files as grounding.', '- If the user request is ambiguous, answer the most likely project-oriented task and state the assumption.' ].join('\n'); } } private buildAstraStanceContext(prompt: string, localPathContext: string): string { const intent = localPathContext ? this.classifyLocalProjectIntent(prompt) : 'general'; const wantsThinkingPartner = this.isThinkingPartnerRequest(prompt) || intent === 'review-evaluation' || intent === 'thinking'; const lines = [ '[ASTRA STANCE LAYER]', 'Use this to make the response feel like Astra thinking with the user, not a template being filled.', '', 'Voice:', '- Warm, direct, and grounded. Do not over-explain the framework.', '- Prefer sentences that sound like a senior collaborator: "나는 여기서 X를 먼저 볼 것 같아요" / "이건 좋아요, 그런데 위험은 Y예요."', '- Avoid sterile balance like "장단점이 있습니다" unless you immediately make a call.', '', 'Judgment habits:', '- State the real bet you think the user is making.', '- Name one thing to keep, one thing to cut, and one thing to verify next when relevant.', '- Use the user’s own goal as the yardstick, not generic best practice.', '- If there are many possible improvements, choose the one that compounds the project fastest.', '', wantsThinkingPartner ? 'For this request, be especially opinionated. Give a clear personal verdict before structure.' : 'For this request, keep the persona light but still make concrete choices.', intent !== 'general' ? `Local project intent for tone: ${intent}` : '' ]; if (intent === 'review-evaluation') { lines.push( '', 'Review stance:', '- Do not merely list strengths and weaknesses. Say whether you would rely on this project today and under what constraint.', '- Prefer the product-owner question: "What has to become boring and reliable before this deserves expansion?"', '- If evidence is shallow, say which file would change your opinion most.' ); } if (intent === 'thinking') { lines.push( '', 'Thinking stance:', '- Do not solve every branch. Reduce the user’s uncertainty to the next decision.', '- A useful answer may say: "I would not expand yet" or "This deserves a spike, not a feature."' ); } return lines.filter(Boolean).join('\n'); } private evaluateAstraAnswerQuality(content: string, prompt: string, localPathContext: string): { needsGate: boolean; hasVerdict: boolean; hasRisk: boolean; hasNextMove: boolean; templateSmell: boolean; } { const intent = localPathContext ? this.classifyLocalProjectIntent(prompt) : 'general'; const needsGate = intent === 'review-evaluation' || intent === 'thinking' || this.isThinkingPartnerRequest(prompt); if (!needsGate) { return { needsGate: false, hasVerdict: true, hasRisk: true, hasNextMove: true, templateSmell: false }; } const hasVerdict = /(Astra 판단|제 판단|내 판단|결론|나는 .{0,20}(?:볼|생각|추천)|먼저 .{0,20}(?:해야|보는)|핵심은|verdict|my take)/i.test(content); const hasRisk = /(위험|리스크|걱정|우려|blind spot|risk|concern|주의)/i.test(content); const hasNextMove = /(다음 한 수|다음 행동|다음 단계|우선순위|먼저 .{0,20}(?:보|하|확인)|next move|next step)/i.test(content); const templateHeadingHits = [ /##\s*요청 요약/i, /##\s*(추론된|인지된|inferred)\s*사용자\s*의도/i, /##\s*프로젝트 기록/i, /Candidate records for this discussion/i, /2nd Brain Trace/i ].filter((pattern) => pattern.test(content)).length; const templateSmell = templateHeadingHits >= 2 && !/Astra 판단|제 판단|내 판단/i.test(content); return { needsGate, hasVerdict, hasRisk, hasNextMove, templateSmell }; } private applyAstraQualityGate(content: string, prompt: string, localPathContext: string): string { const quality = this.evaluateAstraAnswerQuality(content, prompt, localPathContext); if (!quality.needsGate || (!quality.templateSmell && quality.hasVerdict && quality.hasRisk && quality.hasNextMove)) { return content; } const intent = localPathContext ? this.classifyLocalProjectIntent(prompt) : 'general'; const additions: string[] = []; if ((!quality.hasVerdict || quality.templateSmell) && !/##\s*Astra 판단/i.test(content)) { additions.push([ '## Astra 판단', this.buildAstraVerdict(prompt, localPathContext, intent) ].join('\n')); } if (!quality.hasRisk && !/(##\s*(리스크|우려|위험)|blind spot)/i.test(content)) { additions.push([ '## 내가 보는 위험', this.buildAstraRisk(prompt, localPathContext, intent) ].join('\n')); } if (!quality.hasNextMove && !/##\s*다음 한 수/i.test(content)) { additions.push([ '## 다음 한 수', this.buildAstraNextMove(prompt, localPathContext, intent) ].join('\n')); } return additions.length ? [additions.join('\n\n'), '', content.trim()].join('\n') : content; } private buildAstraVerdict(prompt: string, localPathContext: string, intent: LocalProjectIntent): string { if (intent === 'review-evaluation') { const projectPath = localPathContext.match(/Path:\s*(.+)/)?.[1]?.trim() || '이 프로젝트'; return `나는 이 요청을 “좋은 말 해주는 평가”가 아니라 실제로 의존해도 되는 도구인지 보는 리뷰로 볼게요. \`${projectPath}\`는 먼저 목적에 맞는 수집 루프가 안정적인지, 끊겼을 때 이어지는지, 결과가 재검증 가능한지를 기준으로 판단하는 게 맞습니다. 기능 확장은 그 다음입니다.`; } if (intent === 'thinking') { return '내 판단은 방향을 넓히기 전에 지금 헷갈리는 선택지를 줄이는 게 먼저라는 쪽입니다. 이 답변은 가능한 모든 선택지를 펼치기보다, 지금 결정하면 다음 작업이 쉬워지는 갈림길을 잡는 데 초점을 둡니다.'; } return '내 판단은 템플릿보다 지금 사용자가 실제로 줄이려는 불확실성을 먼저 잡아야 한다는 쪽입니다. 그래서 답변은 정보 나열보다 선택과 다음 행동 중심으로 봅니다.'; } private buildAstraRisk(prompt: string, localPathContext: string, intent: LocalProjectIntent): string { if (intent === 'review-evaluation') { return '가장 큰 위험은 구조가 좋아 보이는 것과 운영에서 믿을 수 있는 것이 다르다는 점입니다. 특히 수집 도구는 실패 복구, 중복 제거, 상태 저장, 진단 로그가 약하면 기능이 많아져도 실제 사용감은 계속 흔들립니다.'; } return '가장 큰 위험은 선택지를 넓히는 동안 실제 다음 행동이 흐려지는 것입니다. 지금은 더 많은 가능성보다 판단 기준 하나를 세우는 편이 낫습니다.'; } private buildAstraNextMove(prompt: string, localPathContext: string, intent: LocalProjectIntent): string { if (intent === 'review-evaluation') { return '다음은 확장 아이디어를 붙이기보다 핵심 루프 하나를 추적하는 겁니다. `engine`이 작업 단위, 재시도, 실패 기록, 결과 저장을 어디서 책임지는지 먼저 확인하고, 그 다음 `diagnostics`가 실제 운영 판단에 충분한 정보를 주는지 보면 됩니다.'; } if (intent === 'thinking') { return '다음 한 수는 “지금 당장 유지할 원칙 1개”와 “아직 확장하지 않을 것 1개”를 정하는 겁니다. 그 두 개가 정해지면 설계가 훨씬 덜 흔들립니다.'; } return '다음 한 수는 답변을 더 길게 만드는 것이 아니라, 지금 결정해야 하는 기준 하나를 명확히 하는 것입니다.'; } private shouldPreflightLocalProjectPath(prompt: string): boolean { return /(검토|리뷰|분석|확인|봐줘|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge)/i.test(prompt) && /\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt); } private isProjectKnowledgeCreationRequest(prompt: string): boolean { return this.classifyLocalProjectIntent(prompt) === 'knowledge-creation'; } private isProjectReviewEvaluationRequest(prompt: string): boolean { return this.classifyLocalProjectIntent(prompt) === 'review-evaluation'; } private classifyLocalProjectIntent(prompt: string): LocalProjectIntent { if (!/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt)) { return 'general'; } const normalized = prompt.replace(/\s+/g, ' ').trim(); const asksReview = /(코드\s*리뷰|코드리뷰|리뷰|검토|평가|봐줘|장점|단점|약점|강점|확장성|문제점|리스크|개선점|의견|판단|괜찮|어때|어떤\s*거?\s*같|review|evaluate|assessment|strength|weakness|pros?\s*and\s*cons?|extensibility|scalability|risk|issue)/i.test(normalized); if (asksReview) { return 'review-evaluation'; } const asksImplementation = /(고쳐|수정|개선해|구현|추가|삭제|리팩토링|디버그|fix|implement|add|remove|refactor|debug)/i.test(normalized); if (asksImplementation) { return 'implementation'; } const explicitKnowledgeCreation = /((?:이|그|현재|해당)?\s*(?:프로젝트|프로그램|코드베이스).{0,20}(?:대한|기반|관련).{0,20}지식.{0,12}(?:만들|생성|정리|문서화|기록|저장))|(지식.{0,12}(?:만들|생성|정리|문서화|기록|저장).{0,20}(?:프로젝트|프로그램|코드베이스))|(project\s+knowledge.{0,20}(?:create|generate|record|document|overview))|((?:create|generate|record|document).{0,20}project\s+knowledge)/i.test(normalized); if (explicitKnowledgeCreation) { return 'knowledge-creation'; } const asksDocumentation = /(문서화(?:해|해줘|를)|문서(?:로)?\s*(?:정리|작성|만들)|README|가이드|wiki|documentation|document\s+this|write\s+docs)/i.test(normalized); if (asksDocumentation) { return 'documentation'; } const asksThinking = /(설계|아키텍처|구조|방향|생각|의견|판단|어떤\s*거?\s*같|어때|architecture|design|structure|direction|opinion|think|judge)/i.test(normalized); if (asksThinking) { return 'thinking'; } return 'general'; } private isProjectKnowledgeFollowupRequest(prompt: string): boolean { return /(아키텍처|구조|조사|분석|설계|흐름|모듈|역할|개선|architecture|structure|design|flow|module|investigate|analy[sz]e)/i.test(prompt); } private isThinkingPartnerRequest(prompt: string): boolean { return /(어떤\s*거?\s*같|어때|어떻게\s*생각|의견|판단|방향|설계|아키텍처|구조|자비스|생각.*정리|갈림길|architecture|design|direction|opinion|think|judge)/i.test(prompt); } private isAstraModeArchitectureQuestion(prompt: string): boolean { const mentionsGuard = /\bguard\b|가드|Guard|Chronicle Guard|Project Chronicle/i.test(prompt); const mentionsMultiAgent = /\bMA\b|multi[-\s]?agent|멀티\s*에이전트|다중\s*에이전트|Planner|Researcher|Writer/i.test(prompt); const asksDecision = /(분리|통합|모드|사용|좋을까|맞을까|구조|설계|아키텍처|의견|판단|어때|어떤\s*거?\s*같|separate|combine|mode|architecture|design|opinion)/i.test(prompt); return asksDecision && mentionsGuard && mentionsMultiAgent; } private shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boolean): boolean { if (!prompt || this.isAstraModeArchitectureQuestion(prompt)) { return false; } if (this.shouldPreflightLocalProjectPath(prompt)) { return false; } const complexByShape = prompt.length > 180 || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|roadmap|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt); if (!complexByShape) { return false; } return configEnabled || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt); } private buildAstraModeArchitectureContext(prompt: string): string { if (!this.isAstraModeArchitectureQuestion(prompt)) { return ''; } return [ '[ASTRA MODE ARCHITECTURE DECISION CONTEXT]', 'The user is asking about Astra itself, specifically whether Guard mode and MA/Multi-Agent mode should remain separate.', '', 'Confirmed implementation facts from the current codebase:', '- Guard is currently exposed as a sidebar toggle, but it defaults to enabled in the webview UI.', '- Guard context is built by buildProjectChronicleGuardContext(activeProject) and passed into AgentExecutor as designerContext.', '- In the normal single-agent path, designerContext is injected into the system prompt as [PROJECT CHRONICLE GUARD].', '- In the Multi-Agent path, designerContext is appended as Project Chronicle Guard context for the workflow manager.', '- Multi-Agent is an internal execution strategy. The legacy g1nation.multiAgentEnabled setting can still force it for complex prompts, but Astra may also select it automatically for report/research/strategy style tasks.', '- Current guardrail: Multi-Agent is not used for local project path preflight or Astra mode-design questions, because those need richer context assembly first.', '', 'Product decision guidance:', '- Do not treat Guard and MA as two equal user-facing modes.', '- Guard should be an always-on policy/context layer: project target, evidence discipline, record hygiene, tone, and decision logging.', '- MA should be an optional execution strategy chosen automatically for genuinely complex tasks.', '- Recommended UX: hide or de-emphasize the Guard toggle, show it as Auto/On by default, and let Astra route between single-agent and MA internally.', '- Recommended answer: give a clear verdict that separating them as peer modes is not ideal; separate them internally by responsibility instead.', '- Mention the concrete risk that MA can currently bypass richer context assembly, so unifying the context preparation before routing is the next engineering step.' ].join('\n'); } private buildJarvisProjectBriefContext(prompt: string, localPathContext: string, recentProjectKnowledgeContext: string): string { if (!this.isThinkingPartnerRequest(prompt)) { return ''; } const sourceContext = localPathContext && localPathContext.includes('Access: succeeded') ? localPathContext : recentProjectKnowledgeContext; if (!sourceContext) { return [ '[JARVIS PROJECT BRIEF]', 'No concrete local project brief is available yet.', 'Use the conversation and Second Brain cautiously. If the user asks about a project architecture, ask for or inspect the project path before making strong claims.', '', this.buildThinkingPartnerResponseContract() ].join('\n'); } const projectPath = sourceContext.match(/Path:\s*(.+)/)?.[1]?.trim() || sourceContext.match(/Repository:\s*`([^`]+)`/)?.[1]?.trim() || sourceContext.match(/project evidence:\s*([^\s]+)/i)?.[1]?.trim() || 'current project'; const evidenceFiles = sourceContext.includes('Priority file previews:') ? this.extractPriorityPreviewFiles(sourceContext).slice(0, 10) : this.extractEvidenceFilesFromProjectKnowledge(sourceContext).slice(0, 10); const treeMatch = sourceContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/); const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 30).join('\n') || ''; return [ '[JARVIS PROJECT BRIEF]', `Project evidence target: ${projectPath}`, evidenceFiles.length ? `Evidence files available:\n${evidenceFiles.map((file) => `- ${file}`).join('\n')}` : 'Evidence files available: not enough concrete file markers were found.', treePreview ? `Visible structure preview:\n${treePreview}` : '', '', this.buildThinkingPartnerResponseContract() ].filter(Boolean).join('\n'); } private buildThinkingPartnerResponseContract(): string { return [ 'Thinking partner response contract:', '1. Start with a direct verdict, not a generic compliment.', '2. Separate confirmed facts from inferences.', '3. Name the strongest part of the direction and the weakest/missing part.', '4. Identify the real decision fork the user is facing.', '5. Suggest one small next action that would make the project direction clearer.', '6. If project evidence is thin, say what must be inspected next instead of pretending certainty.' ].join('\n'); } private buildRequestHistory(history: ChatMessage[]): ChatMessage[] { return history.map((message) => { if (message.role !== 'assistant' || typeof message.content !== 'string') { return message; } return { ...message, content: this.sanitizeHistoryAssistantContent(message.content) }; }); } private sanitizeHistoryAssistantContent(content: string): string { return content .replace(/
\s*2nd Brain Trace:[\s\S]*?<\/details>/gi, '') .replace(/## Second Brain Debug JSON[\s\S]*?(?=\n## |\n# |$)/gi, '') .replace(/## Candidate records for this discussion[\s\S]*?(?=\n## |\n# |$)/gi, '') .replace(/## 후보 기록[\s\S]*?(?=\n## |\n# |$)/gi, '') .replace(/## 프로젝트 기록 검토[\s\S]*?(?=\n## |\n# |$)/gi, '') .replace(/\n{3,}/g, '\n\n') .trim(); } private buildRecentProjectKnowledgeContext(prompt: string, rootPath: string): string { if (!rootPath || !this.isProjectKnowledgeFollowupRequest(prompt)) { return ''; } const recordPath = this.findRecentProjectKnowledgeRecord(rootPath); if (!recordPath) { return ''; } try { const content = fs.readFileSync(recordPath, 'utf8'); return [ '[RECENT LOCAL PROJECT KNOWLEDGE]', 'The current user request appears to continue a previous local project knowledge discussion.', `Use this recently generated project knowledge record as project evidence: ${recordPath}`, 'When answering, explicitly say that the analysis is based on the recently generated project knowledge record and local project structure. Do not imply that Second Brain Trace was the only evidence.', 'If deeper architecture detail is needed, recommend reading the concrete source files next instead of asking for the project path again.', '', summarizeText(content, 5000) ].join('\n'); } catch (error: any) { logError('Failed to load recent project knowledge record.', { recordPath, error: error?.message || String(error) }); return ''; } } private ensureRecentProjectKnowledgeEvidence(content: string, recentProjectKnowledgeContext: string): string { const recordPath = this.extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext); if (!recordPath || content.includes(recordPath)) { return content; } const evidenceFiles = this.extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext).slice(0, 8); const evidenceSection = [ '## 근거', `이번 답변은 최근 생성된 프로젝트 지식 기록과 로컬 프로젝트 구조를 기준으로 작성했습니다: \`${recordPath}\``, evidenceFiles.length ? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}` : '' ].filter(Boolean).join('\n\n'); return [ content.trim(), '', evidenceSection ].join('\n'); } private ensureLocalProjectPathEvidence(content: string, localPathContext: string): string { if (!localPathContext.includes('Access: succeeded') || content.includes('## 근거')) { return content; } const pathMatch = localPathContext.match(/Path:\s*(.+)/); const projectPath = pathMatch?.[1]?.trim(); const evidenceFiles = this.extractPriorityPreviewFiles(localPathContext).slice(0, 10); if (!projectPath && evidenceFiles.length === 0) { return content; } const evidenceSection = [ '## 근거', projectPath ? `이번 답변은 로컬 프로젝트 경로 \`${projectPath}\`에서 확인한 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.` : '이번 답변은 확인된 로컬 프로젝트 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.', evidenceFiles.length ? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}` : '' ].filter(Boolean).join('\n\n'); return [ content.trim(), '', evidenceSection ].join('\n'); } private extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext: string): string | null { return recentProjectKnowledgeContext.match(/project evidence:\s*(\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+\.md)/i)?.[1] || null; } private extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext: string): string[] { const evidenceBlock = recentProjectKnowledgeContext.match(/## Evidence Files\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || ''; const evidenceFiles = [...evidenceBlock.matchAll(/-\s+`([^`]+)`/g)].map((match) => match[1].trim()); if (evidenceFiles.length > 0) { return Array.from(new Set(evidenceFiles)); } const structureBlock = recentProjectKnowledgeContext.match(/## Confirmed Structure\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || ''; return Array.from(new Set([...structureBlock.matchAll(/`([^`]+)`/g)] .map((match) => match[1].trim()) .filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value)))); } private findRecentProjectKnowledgeRecord(rootPath: string): string | null { const fromHistory = [...this.chatHistory] .reverse() .map((message) => typeof message.content === 'string' ? message.content.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+_project_knowledge_overview\.md/i)?.[0] : undefined) .find(Boolean); if (fromHistory && fs.existsSync(fromHistory)) { return fromHistory; } const recordsRoot = path.join(rootPath, 'docs', 'records'); if (!fs.existsSync(recordsRoot)) { return null; } const candidates: string[] = []; const visit = (dir: string, depth: number) => { if (depth > 5) return; let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (!entry.name.startsWith('.')) visit(fullPath, depth + 1); continue; } if (/_project_knowledge_overview\.md$/i.test(entry.name)) { candidates.push(fullPath); } } }; visit(recordsRoot, 0); return candidates .filter((file) => fs.existsSync(file)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || null; } private extractLocalProjectPaths(prompt: string): string[] { const matches = prompt.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/gi) || []; return Array.from(new Set(matches.map((value) => value.replace(/[),.;\]]+$/g, '')))); } private inspectLocalProjectPath(targetPath: string, rootPath: string): string { try { const absPath = validatePath(rootPath, targetPath); if (!fs.existsSync(absPath)) { return [ `Path: ${targetPath}`, 'Access: failed', 'Reason: path does not exist in the current environment.' ].join('\n'); } const stat = fs.statSync(absPath); if (!stat.isDirectory()) { const content = fs.readFileSync(absPath, 'utf8'); return [ `Path: ${targetPath}`, 'Access: succeeded', 'Type: file', `Preview:\n${summarizeText(content, 1200)}` ].join('\n'); } const tree = this.listProjectTree(absPath, absPath, 0, 4, 140); const priorityFiles = this.findPriorityProjectFiles(absPath).slice(0, 12); const previews = priorityFiles.map((file) => { try { const content = fs.readFileSync(file, 'utf8'); return [ `File: ${path.relative(absPath, file)}`, summarizeText(content, 2200) ].join('\n'); } catch (error: any) { return `File: ${path.relative(absPath, file)}\nRead failed: ${error.message}`; } }).join('\n\n'); return [ `Path: ${targetPath}`, 'Access: succeeded', 'Type: directory', `Scanned tree:\n${tree || '(no visible files found)'}`, priorityFiles.length > 0 ? `Priority file previews:\n${previews}` : 'Priority file previews: no package, README, docs, src, or config files found in the first scan.' ].join('\n'); } catch (error: any) { return [ `Path: ${targetPath}`, 'Access: failed', `Reason: ${error.message}` ].join('\n'); } } private enforceLocalPathReviewAnswer(content: string, localPathContext: string): string { if (!localPathContext.includes('Access: succeeded')) { return content; } const asksForUpload = /(코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공|핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요|folder path is not enough|upload (?:the )?(?:source )?code|please provide (?:the )?files)/i.test(content); const deniesCodeAccess = /(실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|기술적인 진단.*수 없습니다)/i.test(content); if (!asksForUpload && !deniesCodeAccess) { return content; } const header = [ '## 경로 확인 결과', '', '제공된 로컬 프로젝트 경로에는 접근할 수 있고, 코드 파일도 확인되었습니다. 따라서 파일 업로드를 요청하는 대신, 확인된 파일 구조와 코드 프리뷰를 기준으로 분석하거나 프로젝트 지식을 만들 수 있습니다.', '', '이전 응답의 "코드/파일/구조를 제공해 주세요" 취지의 문장은 잘못된 안내입니다.' ].join('\n'); return [ header, '', content .replace(/.*(?:코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공).*$/gmi, '') .replace(/.*(?:핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요).*$/gmi, '') .replace(/.*(?:실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없).*$/gmi, '') .trim() ].filter(Boolean).join('\n\n'); } private isBlockingProjectKnowledgeAnswer(content: string): boolean { return /(블로킹 질문|어떤 기능 영역|어떤 부분.*먼저|어떤 기능이나 아키텍처|구체적인 방향|방향 설정이 필요|명확히 알려주시면|우선적으로 정리|최종 사용 목적|Question reason|별도의 파일 기록.*생성되지|파일 기록이 생성되지|더 깊이 있는 분석.*지정|해당 기능.*지정하여 요청)/i.test(content); } private isMisroutedProjectKnowledgeAnswer(content: string): boolean { return /(기본 지식 생성 방향|바로 만들 지식 초안|Project Knowledge Overview|프로젝트 지식 1번 문서|프로젝트 지식 기록을 생성|프로젝트 지식.*만들면|지식 생성 작업)/i.test(content); } private isShallowProjectReviewAnswer(content: string): boolean { if (!/##\s*(코드리뷰|평가|장점|단점|확장성|리스크|개선|상세 답변)/i.test(content)) { return true; } const requiredSignals = [ /(목적|purpose|fit)/i, /(아키텍처|구조|architecture|structure)/i, /(흐름|flow|pipeline|control|data)/i, /(실패|복구|재시도|failure|recovery|retry)/i, /(운영|관측|로그|diagnostics|observability|operability)/i, /(확장성|extensibility|scalability)/i ]; const hits = requiredSignals.filter((pattern) => pattern.test(content)).length; return hits < 4; } private buildProjectReviewFallbackAnswer(localPathContext: string): string { const pathMatch = localPathContext.match(/Path:\s*(.+)/); const projectPath = pathMatch?.[1]?.trim() || '제공된 로컬 프로젝트 경로'; const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/); const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 18).join('\n') || ''; const priorityFiles = this.extractPriorityPreviewFiles(localPathContext).slice(0, 10); const fileList = priorityFiles.length ? priorityFiles.map((file) => `- \`${file}\``).join('\n') : '- 우선 확인 파일을 충분히 찾지 못했습니다.'; return [ '## 간단 요약', '이 요청은 프로젝트 지식 생성이 아니라 코드리뷰와 제품 평가 요청입니다. 확인된 파일 구조 기준으로 보면, 이 프로젝트는 지식 수집 워크플로우를 앱 형태로 묶어 운영하려는 도구로 보이며, 먼저 데이터 수집 흐름의 안정성, 외부 연동 실패 처리, 수집 결과의 저장/재처리 가능성을 중심으로 평가해야 합니다.', '', '## 확인된 근거', `대상 경로: \`${projectPath}\``, '', '확인된 우선 파일:', fileList, treePreview ? `\n확인된 구조 일부:\n\`\`\`text\n${treePreview}\n\`\`\`` : '', '', '## 코드리뷰 관점 평가', '1. 목적 적합성: 지식 수집 프로그램이라면 핵심은 “많이 가져오기”보다 “끊겨도 이어지고, 중복 없이 남고, 다시 검증할 수 있는 수집 흐름”입니다. 현재 구조에서는 `engine`, `api`, `diagnostics`, `gemini`처럼 수집 실행, 외부 연동, 진단, 모델 연동으로 보이는 책임이 드러나므로 목적에 맞는 기본 골격은 있습니다.', '', '2. 아키텍처 형태: `src/lib/*` 아래에 실행 계층이 모이고 `components/AgentDashboard.tsx`가 UI 관측면을 담당하는 형태로 보입니다. 이 분리는 좋지만, 실제로 UI가 엔진 내부 상태를 직접 많이 알고 있으면 확장 때 결합이 커질 수 있습니다.', '', '3. 데이터/제어 흐름: 다음 리뷰의 핵심은 `src/lib/engine.ts`가 작업 큐, 수집 단계, 결과 저장, 재시도 정책을 어디까지 책임지는지 확인하는 것입니다. 수집 도구의 품질은 이 흐름이 명시적인 상태 머신처럼 보이느냐에 달려 있습니다.', '', '4. 실패 복구: 현재 확인된 프리뷰만으로는 수집 실패 후 재시도, 중복 수집 방지, 인증 만료 복구, 장기 실행 상태 저장이 충분히 검증되지 않았습니다. 이게 가장 위험한 blind spot입니다.', '', '5. 운영성/관측성: `diagnostics.ts`가 있다는 점은 강점입니다. 다만 진단이 단순 상태 표시인지, 실패 원인/마지막 성공 지점/재개 가능 여부까지 기록하는지 확인해야 합니다.', '', '6. 확장성: 확장성은 기능 추가보다 파이프라인 안정화에서 나옵니다. 수집 대상이 늘어날수록 커넥터 인터페이스, 결과 스키마, 중복 제거, 품질 점수화가 먼저 필요합니다.', '', '## 확장성 방향', '확장성은 기능을 더 붙이는 방향보다 수집 파이프라인을 안정화하는 쪽으로 잡는 것이 좋습니다. 우선순위는 1. 수집 작업 단위 정의, 2. 상태 저장과 재개, 3. 실패 원인 기록, 4. 중복 제거, 5. 수집 결과 품질 점수화, 6. Second Brain 저장 포맷 표준화 순서가 좋습니다.', '', '## 다음에 깊게 볼 파일', '다음 리뷰에서는 `src/lib/engine.ts`, `src/lib/api.ts`, `src/lib/diagnostics.ts`, `src/lib/gemini.ts`, `src/components/AgentDashboard.tsx`를 순서대로 보면 됩니다. 특히 `engine.ts`가 실제 수집 플로우의 중심인지 확인하고, 실패/재시도/상태 저장이 어디서 책임지는지 보는 게 첫 번째입니다.' ].filter(Boolean).join('\n'); } private buildProjectKnowledgeFallbackAnswer(localPathContext: string, record?: { filePath: string; relativePath: string } | null): string { const pathMatch = localPathContext.match(/Path:\s*(.+)/); const projectPath = pathMatch?.[1]?.trim() || '제공된 로컬 프로젝트 경로'; const projectDisplayName = this.getProjectDisplayName(projectPath); const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/); const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 18).join('\n') || ''; const priorityMatches = this.extractPriorityPreviewFiles(localPathContext).slice(0, 10); const priorityText = priorityMatches.length ? priorityMatches.map((file) => `- ${file}`).join('\n') : '- package.json, src, docs, config 계열 파일을 우선 확인'; return [ '## 간단 요약', '맞아요. 이 경우에는 추가 질문으로 멈출 필요 없이, 지금 확인된 로컬 프로젝트 구조를 기준으로 기본 프로젝트 지식을 바로 만들면 됩니다.', '', '## 기본 지식 생성 방향', `대상 프로젝트는 \`${projectPath}\`입니다. 우선 MVP 지식은 “프로젝트 개요 + 주요 모듈 + 확인된 근거 파일 + 다음에 깊게 볼 영역” 형태로 만드는 것이 가장 안전합니다.`, '', '## 확인된 근거', priorityText, '', treePreview ? `## 확인된 구조 일부\n\`\`\`text\n${treePreview}\n\`\`\`` : '', '', '## 바로 만들 지식 초안', '```markdown', `# ${projectDisplayName} Project Knowledge Overview`, '', '## Purpose', `${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`, '', '## Confirmed Structure', '- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.', '- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.', '- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.', '- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.', '- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.', '- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.', '', '## Current Knowledge Gap', '- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.', '', '## Recommended Next Record', `- \`docs/records/${path.basename(projectPath)}/development/YYYY-MM-DD_${projectDisplayName.toLowerCase()}_project_knowledge_overview.md\``, '```', '', '## 다음 액션', record ? `프로젝트 지식 1번 문서를 생성했습니다: \`${record.filePath}\`` : '기본값으로는 위 초안을 프로젝트 지식 1번 문서로 저장하고, 그 다음 `agent.ts` 실행 흐름 지식을 별도 문서로 쪼개는 것이 좋습니다.' ].filter(Boolean).join('\n'); } private extractPriorityPreviewFiles(localPathContext: string): string[] { const fileMarkerMatches = [...localPathContext.matchAll(/^File:\s*(.+)$/gmi)] .map((match) => match[1].trim()); if (fileMarkerMatches.length > 0) { return Array.from(new Set(fileMarkerMatches)); } const previewBlock = localPathContext.match(/Priority file previews:\n([\s\S]*)/)?.[1] || ''; return Array.from(new Set([...previewBlock.matchAll(/^###\s+(.+)$/gmi)] .map((match) => match[1].trim()) .filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value)))); } private writeProjectKnowledgeRecord(localPathContext: string): { filePath: string; relativePath: string } | null { const pathMatch = localPathContext.match(/Path:\s*(.+)/); const projectPath = pathMatch?.[1]?.trim(); if (!projectPath || !localPathContext.includes('Access: succeeded')) return null; try { const projectName = path.basename(projectPath); const projectDisplayName = this.getProjectDisplayName(projectPath); const today = new Date().toISOString().slice(0, 10); const slug = projectDisplayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project'; const relativePath = path.join('docs', 'records', projectName, 'development', `${today}_${slug}_project_knowledge_overview.md`); const filePath = path.join(projectPath, relativePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, this.buildProjectKnowledgeMarkdown(localPathContext), 'utf8'); return { filePath, relativePath }; } catch (error: any) { logError('Failed to write project knowledge record.', { error: error?.message || String(error) }); return null; } } private buildProjectKnowledgeMarkdown(localPathContext: string): string { const pathMatch = localPathContext.match(/Path:\s*(.+)/); const projectPath = pathMatch?.[1]?.trim() || 'Unknown project path'; const projectName = path.basename(projectPath); const projectDisplayName = this.getProjectDisplayName(projectPath); const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/); const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 80).join('\n') || ''; const priorityFiles = this.extractPriorityPreviewFiles(localPathContext); return [ `# ${projectDisplayName} Project Knowledge Overview`, '', `Date: ${new Date().toISOString()}`, `Project: ${projectDisplayName}`, `Repository: \`${projectPath}\``, '', '## Purpose', `${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`, '', '## Confirmed Structure', '- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.', '- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.', '- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.', '- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.', '- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.', '- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.', '', '## Evidence Files', ...(priorityFiles.length ? priorityFiles.map((file) => `- \`${file}\``) : ['- 확인된 우선 파일 없음']), '', '## Scanned Tree Excerpt', '```text', treePreview || '(no scanned tree captured)', '```', '', '## Current Knowledge Gap', '- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.', '', '## Next Records', '- `agent.ts` 실행 흐름 상세 분석', '- Second Brain Trace 검색 및 근거 정책 분석', '- Project Chronicle 기록 생성 흐름 분석' ].join('\n'); } private getProjectDisplayName(projectPath: string): string { const projectName = path.basename(projectPath); return /^connectai$/i.test(projectName) ? 'Astra' : projectName; } private listProjectTree(root: string, current: string, depth: number, maxDepth: number, limit: number): string { if (limit <= 0 || depth > maxDepth) { return ''; } let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(current, { withFileTypes: true }) .filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) .sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)); } catch { return ''; } const lines: string[] = []; for (const entry of entries) { if (lines.length >= limit) break; const fullPath = path.join(current, entry.name); const relative = path.relative(root, fullPath); lines.push(`${' '.repeat(depth)}${relative}${entry.isDirectory() ? '/' : ''}`); if (entry.isDirectory() && depth < maxDepth) { const child = this.listProjectTree(root, fullPath, depth + 1, maxDepth, limit - lines.length); if (child) { lines.push(child); } } } return lines.join('\n'); } private findPriorityProjectFiles(root: string): string[] { const exactNames = new Set([ 'package.json', 'README.md', 'readme.md', 'tsconfig.json', 'vite.config.ts', 'vite.config.js', 'next.config.js', 'next.config.mjs', 'webpack.config.js' ]); const results: string[] = []; const visit = (dir: string, depth: number, inSourceArea: boolean) => { if (depth > 6 || results.length >= 24) return; let entries: fs.Dirent[] = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }) .filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { const nextInSourceArea = inSourceArea || /^(src|app|pages|components|docs|lib|server|backend|frontend|config|features|core|hooks|systems|store|model|utils|ui|api)$/i.test(entry.name); if (nextInSourceArea) { visit(fullPath, depth + 1, nextInSourceArea); } continue; } const relative = path.relative(root, fullPath); const isSourceCode = /\.(ts|tsx|js|jsx)$/i.test(entry.name); if ( exactNames.has(entry.name) || (inSourceArea && isSourceCode) || /(^|[\\/])(src|app|pages|components|docs|lib|server|backend|frontend|features|core)[\\/].+\.(ts|tsx|js|jsx|md|json)$/i.test(relative) || /\.(config|rc)\.(js|ts|json)$/i.test(entry.name) ) { results.push(fullPath); } } }; visit(root, 0, false); return Array.from(new Set(results)).sort((a, b) => { const rank = (file: string) => { const relative = path.relative(root, file); if (path.basename(file) === 'package.json') return 0; if (/readme\.md$/i.test(file)) return 1; if (/^src[\\/]App\.tsx$/i.test(relative)) return 2; if (/^src[\\/]main\.tsx$/i.test(relative)) return 3; if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4; if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5; if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6; if (/^src[\\/]/i.test(relative)) return 7; if (/^docs[\\/]|\.md$/i.test(relative)) return 8; return 9; }; return rank(a) - rank(b) || a.localeCompare(b); }); } private buildMemoryContext(currentPrompt: string, activeBrain: BrainProfile): string { 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('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 }; }); 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 ''; } } private emitHistoryChanged() { if (!this.historyChangeListener) return; // Save session whenever history changes this.sessionManager.saveSession( this.currentTaskId, this.chatHistory, this.context.workspaceState.get('lastActionStr') ); 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; reqMessages: ChatMessage[]; temperature: number; }): Promise<{ response: Response; engine: 'lmstudio' | 'ollama'; apiUrl: string }> { const { baseUrl, modelName, reqMessages, temperature } = params; const primaryEngine = resolveEngine(baseUrl); const engines = primaryEngine === 'lmstudio' ? ['lmstudio', 'ollama'] as const : ['ollama', 'lmstudio'] as const; let lastError: Error | null = null; for (const engine of engines) { const apiUrl = buildApiUrl(baseUrl, engine, 'chat'); const messageVariants = this.buildEngineMessageVariants(reqMessages, engine); const modelCandidates = this.buildModelCandidates(modelName, engine); 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 } }), }; 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 }); } } } } 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, activeBrain: BrainProfile): Promise { const report: string[] = []; let brainModified = false; const activeBrainDir = activeBrain.localBrainPath; let firstCreatedFile: string | undefined; try { this.transactionManager.begin(); // Action 1: Create File const createRegex = /([\s\S]*?)<\/create_file>/gi; let match; while ((match = createRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim(); const content = match[2].trim(); try { const absPath = validatePath(rootPath, relPath); await this.transactionManager.record(absPath); fs.mkdirSync(path.dirname(absPath), { recursive: true }); fs.writeFileSync(absPath, content, 'utf-8'); report.push(`✅ Created: ${relPath}`); if (!firstCreatedFile) firstCreatedFile = absPath; if (absPath.startsWith(activeBrainDir)) brainModified = true; } catch (err: any) { throw new FileSystemError(`Failed to create file ${relPath}: ${err.message}`, relPath, err); } } // Action 2: Edit File const editRegex = /([\s\S]*?)<\/edit_file>/gi; while ((match = editRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim(); const editContent = match[2].trim(); try { const absPath = validatePath(rootPath, relPath); if (fs.existsSync(absPath)) { await this.transactionManager.record(absPath); let currentContent = fs.readFileSync(absPath, 'utf-8'); const searchMatch = editContent.match(/([\s\S]*?)<\/search>\s*([\s\S]*?)<\/replace>/i); if (searchMatch) { const searchStr = searchMatch[1]; const replaceStr = searchMatch[2]; if (currentContent.includes(searchStr)) { currentContent = currentContent.replace(searchStr, replaceStr); fs.writeFileSync(absPath, currentContent, 'utf-8'); report.push(`📝 Updated: ${relPath}`); } else { report.push(`⚠️ Search string not found in ${relPath}`); } } else { fs.writeFileSync(absPath, editContent, 'utf-8'); report.push(`📝 Updated (Full): ${relPath}`); } if (absPath.startsWith(activeBrainDir)) brainModified = true; } else { report.push(`❌ File not found: ${relPath}`); } } catch (err: any) { throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err); } } // Action 3: Delete File const deleteRegex = /(?:<\/delete_file>)?/gi; while ((match = deleteRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim(); try { const absPath = validatePath(rootPath, relPath); if (fs.existsSync(absPath)) { await this.transactionManager.record(absPath); fs.unlinkSync(absPath); report.push(`🗑 Deleted: ${relPath}`); } else { report.push(`⚠️ Delete failed: ${relPath} not found`); } } catch (err: any) { throw new FileSystemError(`Failed to delete file ${relPath}: ${err.message}`, relPath, err); } } // Action 4: Read File (Non-state-changing, no transaction record needed) const readRegex = /(?:<\/read_file>)?/gi; while ((match = readRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim(); try { const absPath = validatePath(rootPath, relPath); if (fs.existsSync(absPath)) { const content = fs.readFileSync(absPath, 'utf-8'); const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content; report.push(`📖 Read: ${relPath}`); this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true }); } else { report.push(`❌ Read failed: ${relPath} not found`); } } catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); } } // Action 5: Run Command const cmdRegex = /([\s\S]*?)<\/run_command>/gi; while ((match = cmdRegex.exec(aiMessage)) !== null) { const cmd = match[1].trim(); try { const safeCmd = sanitizeCommand(cmd); const terminal = vscode.window.terminals.find(t => t.name === 'Astra Terminal') || vscode.window.createTerminal({ name: 'Astra Terminal', cwd: rootPath }); terminal.show(); terminal.sendText(safeCmd); report.push(`🚀 Executed: ${safeCmd}`); } catch (err: any) { report.push(`❌ Blocked: ${err.message}`); } } // Action 6: List Files const listRegex = /(?:<\/list_files>)?/gi; while ((match = listRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim() || '.'; try { const absPath = validatePath(rootPath, relPath); if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) { const entries = fs.readdirSync(absPath, { withFileTypes: true }); let listing = entries .filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name)) .map(e => e.isDirectory() ? `${e.name}/` : e.name) .join('\n'); if (listing.length > 5000) { listing = listing.slice(0, 5000) + "\n... (truncated for context)"; } report.push(`📂 Listed: ${relPath}`); this.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true }); } } catch (err: any) { report.push(`❌ Listing failed: ${err.message}`); } } // Action 7: Second Brain Knowledge (List/Read) const listBrainRegex = /(?:<\/list_brain>)?/gi; while ((match = listBrainRegex.exec(aiMessage)) !== null) { const relPath = match[1].trim() || '.'; try { const brainDir = activeBrainDir; const absPath = path.join(brainDir, relPath); if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) { const entries = fs.readdirSync(absPath, { withFileTypes: true }); let listing = entries .filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name)) .map(e => e.isDirectory() ? `${e.name}/` : e.name) .join('\n'); if (listing.length > 5000) { listing = listing.slice(0, 5000) + "\n... (truncated for context)"; } report.push(`🧠 Brain Listed: ${relPath}`); this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true }); } else { report.push(`❌ Brain List failed: ${relPath} not found`); } } catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); } } const brainRegex = /([\s\S]*?)<\/read_brain>/gi; while ((match = brainRegex.exec(aiMessage)) !== null) { const fileName = match[1].trim(); try { const brainDir = activeBrainDir; const files = findBrainFiles(brainDir); const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName)); if (targetFile && fs.existsSync(targetFile)) { const content = fs.readFileSync(targetFile, 'utf-8'); report.push(`🧠 Brain Read: ${fileName}`); this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true }); } else { report.push(`❌ Brain Read failed: ${fileName} not found in Second Brain`); } } catch (err: any) { report.push(`❌ Error Reading Brain: ${err.message}`); } } // Action 8: Read URL const urlRegex = /([\s\S]*?)<\/read_url>/gi; while ((match = urlRegex.exec(aiMessage)) !== null) { const url = match[1].trim(); try { const res = await fetch(url, { signal: AbortSignal.timeout(10000) }); const text = await res.text(); const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content; report.push(`🌐 Read URL: ${url}`); this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true }); } catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); } } if (firstCreatedFile) { vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false }); } // Brain Sync Logic if (brainModified && shouldAutoPushBrain() && activeBrain.secondBrainRepo) { this.syncBrain(activeBrainDir); } const config = getConfig(); if (config.dryRun) { report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`); this.webview?.postMessage({ type: 'requiresApproval' }); // Do NOT commit yet } else { this.transactionManager.commit(); } } catch (error: any) { this.transactionManager.rollback(); const g1Error = error instanceof AgentExecutionError ? error : new AgentExecutionError(error.message, error); report.push(`🛑 Transaction Failed: ${g1Error.message}. All file changes rolled back.`); logError('Action execution failed, rolled back.', g1Error); // We return the report with the failure message instead of throwing // so the agent can see the failure and decide what to do next } return report; } private syncBrain(brainDir: string) { try { const { execSync } = require('child_process'); execSync(`git add .`, { cwd: brainDir }); execSync(`git commit -m "[Astra] Knowledge Update"`, { cwd: brainDir }); execSync(`git push`, { cwd: brainDir }); } catch (err) { logError('Second Brain sync failed.', err); } } }