Files
connectai/src/agent.ts
T

1533 lines
76 KiB
TypeScript

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<void>;
// --- Agent Roles & Workflows ---
export type AgentRole = 'planner' | 'researcher' | 'writer';
const AGENT_PROMPTS: Record<AgentRole, string> = {
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 <plan> 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 <research_results> 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(/<rationale>([\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(/<rationale>[\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 (for complex requests)
const isComplex = prompt && (prompt.length > 100 || /(분석|보고서|설계|기획|정리)/.test(prompt));
if (isComplex && loopDepth === 0 && multiAgentEnabled) {
return this.executeMultiAgentWorkflow(prompt!, modelName, options);
}
const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
let requestTimeoutHandle: ReturnType<typeof setTimeout> | 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}`;
}
// 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.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 <read_url> to search. Current time: ${new Date().toLocaleString()}`
: '';
const selectedAgentSystemPrompt = !multiAgentEnabled && 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 = multiAgentEnabled && options.agentSkillContext
? `\n\n[SELECTED AGENT REFERENCE]\nUse this selected Agent skill as additional preference context when it is relevant.\n${options.agentSkillContext}`
: 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 secondBrainTraceCtx = secondBrainTrace
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
: '';
const memoryCtx = this.buildMemoryContext(prompt || '', activeBrain);
const fullSystemPrompt = `${agentSkillCtx}\n\n${systemPrompt}${internetCtx}${memoryCtx}${designerCtx}${localProjectKnowledgeCtx}${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.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');
}
}
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<string>('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<string> {
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<string, number>();
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<string, number>();
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 sections: string[] = [
'[LOCAL PROJECT PATH PREFLIGHT]',
'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.',
'For knowledge creation requests, answer that the project can be summarized from the inspected local path and propose or execute a project knowledge note based on the previews.'
];
for (const candidate of candidates.slice(0, 2)) {
sections.push(this.inspectLocalProjectPath(candidate, rootPath));
}
return sections.join('\n');
}
private shouldPreflightLocalProjectPath(prompt: string): boolean {
return /(검토|리뷰|분석|확인|봐줘|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve)/i.test(prompt)
&& /\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt);
}
private isProjectKnowledgeCreationRequest(prompt: string): boolean {
return /(지식|문서화|문서|정리|기록|위키|저장|만들|생성|knowledge|document|documentation|wiki|summari[sz]e)/i.test(prompt)
&& /\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i.test(prompt);
}
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 buildProjectKnowledgeFallbackAnswer(localPathContext: string, record?: { filePath: string; relativePath: string } | null): 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 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',
'# ConnectAI Project Knowledge Overview',
'',
'## Purpose',
'ConnectAI는 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/ConnectAI/development/YYYY-MM-DD_connectai_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 today = new Date().toISOString().slice(0, 10);
const slug = projectName.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 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 [
`# ${projectName} Project Knowledge Overview`,
'',
`Date: ${new Date().toISOString()}`,
`Project: ${projectName}`,
`Repository: \`${projectPath}\``,
'',
'## Purpose',
`${projectName}는 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 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<any[]>('chat_sessions', []) || [];
const mediumTerm = savedSessions
.slice(0, config.memoryMediumTermSessions)
.map((session: any) => {
const title = summarizeText(String(session?.title || 'Untitled session'), 120);
const lastMessage = Array.isArray(session?.history)
? session.history[session.history.length - 1]?.content || ''
: '';
return `- ${title}: ${summarizeText(String(lastMessage), 220)}`;
})
.join('\n');
const longTerm = this.findRelevantBrainMemory(currentPrompt, config.memoryLongTermFiles, activeBrain);
const sections = [
shortTerm ? `### Short-Term Memory\n${shortTerm}` : '',
mediumTerm ? `### Medium-Term Memory\n${mediumTerm}` : '',
longTerm ? `### Long-Term Memory\n${longTerm}` : ''
].filter(Boolean).join('\n\n');
if (!sections) return '';
return [
'',
'[MEMORY CONTEXT]',
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
sections
].join('\n');
}
private findRelevantBrainMemory(currentPrompt: string, limit: number, activeBrain: BrainProfile): string {
if (limit <= 0) return '';
try {
const files = findBrainFiles(activeBrain.localBrainPath);
const terms = currentPrompt
.toLowerCase()
.split(/[^a-z0-9가-힣_]+/g)
.filter((term) => term.length >= 2)
.slice(0, 24);
const scored = files.map((file) => {
let score = 0;
const basename = path.basename(file).toLowerCase();
for (const term of terms) {
if (basename.includes(term)) score += 4;
}
let preview = '';
try {
const content = fs.readFileSync(file, 'utf8');
const lower = content.toLowerCase();
for (const term of terms) {
if (lower.includes(term)) score += 1;
}
preview = summarizeText(content, 360);
} catch {
preview = '';
}
const stat = fs.existsSync(file) ? fs.statSync(file) : undefined;
return { file, score, preview, mtime: stat?.mtimeMs || 0 };
});
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<string>('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<string[]> {
const report: string[] = [];
let brainModified = false;
const activeBrainDir = activeBrain.localBrainPath;
let firstCreatedFile: string | undefined;
try {
this.transactionManager.begin();
// Action 1: Create File
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\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 = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\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(/<search>([\s\S]*?)<\/search>\s*<replace>([\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\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/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\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/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 = /<run_command>([\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 === 'G1nation Terminal') || vscode.window.createTerminal({ name: 'G1nation 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\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/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\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/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 = /<read_brain>([\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 = /<read_url>([\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 "[G1nation] Knowledge Update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
} catch (err) {
logError('Second Brain sync failed.', err);
}
}
}