feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)
This commit is contained in:
+428
-179
@@ -3,10 +3,8 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
// axios removed
|
||||
import {
|
||||
getConfig,
|
||||
_getBrainDir,
|
||||
findBrainFiles,
|
||||
EXCLUDED_DIRS,
|
||||
getSystemPrompt,
|
||||
shouldAutoPushBrain,
|
||||
getSecondBrainRepo,
|
||||
@@ -17,16 +15,53 @@ import {
|
||||
resolveEngine,
|
||||
summarizeText
|
||||
} from './utils';
|
||||
import { 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 { ErrorTranslator } from './core/errorHandler';
|
||||
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';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string | any[];
|
||||
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;
|
||||
@@ -34,10 +69,44 @@ export class AgentExecutor {
|
||||
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 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;
|
||||
@@ -75,6 +144,20 @@ export class AgentExecutor {
|
||||
this.emitHistoryChanged();
|
||||
}
|
||||
|
||||
public async approveTransaction() {
|
||||
if (!this.transactionManager.isActive()) return;
|
||||
this.transactionManager.commit();
|
||||
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();
|
||||
this.statusBarManager.updateStatus(AgentStatus.Idle, 'Changes rolled back.');
|
||||
this.webview?.postMessage({ type: 'streamChunk', value: '\n❌ **작업이 거부되어 모든 변경사항이 취소되었습니다.**' });
|
||||
}
|
||||
|
||||
public async handlePrompt(
|
||||
prompt: string | null,
|
||||
modelName: string,
|
||||
@@ -98,19 +181,35 @@ export class AgentExecutor {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -170,7 +269,8 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
// 3. API Request Setup
|
||||
const { ollamaUrl, defaultModel, timeout } = getConfig();
|
||||
const { ollamaUrl, defaultModel: configDefaultModel, timeout } = getConfig();
|
||||
const actualModel = modelName || configDefaultModel;
|
||||
const reqMessages = [...this.chatHistory];
|
||||
|
||||
// Handle Vision Content Injection
|
||||
@@ -184,7 +284,7 @@ export class AgentExecutor {
|
||||
: [];
|
||||
reqMessages[lastUserIdx] = {
|
||||
role: 'user',
|
||||
content: [...textParts, ...(visionContent || [])]
|
||||
content: JSON.stringify([...textParts, ...(visionContent || [])])
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -208,12 +308,12 @@ export class AgentExecutor {
|
||||
// 4. Call AI Engine
|
||||
this.abortController = new AbortController();
|
||||
requestTimeoutHandle = setTimeout(() => {
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: modelName || defaultModel, loopDepth });
|
||||
logError('AI request timed out.', { timeoutMs: timeout, model: actualModel, loopDepth });
|
||||
this.abortController?.abort();
|
||||
}, timeout);
|
||||
const request = await this.createStreamingRequest({
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: modelName || defaultModel,
|
||||
modelName: actualModel,
|
||||
reqMessages: messagesForRequest,
|
||||
temperature
|
||||
});
|
||||
@@ -283,12 +383,15 @@ export class AgentExecutor {
|
||||
}
|
||||
|
||||
// 5. Execute Actions
|
||||
const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true };
|
||||
const rationale = this.parseRationale(aiResponseText);
|
||||
const assistantMessage: ChatMessage = { role: 'assistant', content: aiResponseText, internal: true, rationale };
|
||||
this.chatHistory.push(assistantMessage);
|
||||
|
||||
this.statusBarManager.updateStatus(AgentStatus.Executing);
|
||||
const report = await this.executeActions(aiResponseText, rootPath);
|
||||
if (!aiResponseText.trim() && report.length === 0) {
|
||||
this.chatHistory.pop();
|
||||
logError('Model returned an empty response without actions.', { model: modelName || defaultModel, loopDepth });
|
||||
logError('Model returned an empty response without actions.', { model: actualModel, loopDepth });
|
||||
this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' });
|
||||
return;
|
||||
}
|
||||
@@ -332,9 +435,11 @@ export class AgentExecutor {
|
||||
|
||||
assistantMessage.internal = false;
|
||||
this.emitHistoryChanged();
|
||||
this.statusBarManager.updateStatus(AgentStatus.Success);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
|
||||
|
||||
} 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}` });
|
||||
@@ -383,16 +488,15 @@ export class AgentExecutor {
|
||||
if (explicitPath) return explicitPath;
|
||||
|
||||
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1];
|
||||
if (!namedProject) return null;
|
||||
if (!namedProject) return null; // No project keyword found, do not attempt to guess.
|
||||
|
||||
const searchRoots = [
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '',
|
||||
'/Volumes/Data/project/Antigravity',
|
||||
'/Volumes/Data/project',
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
||||
].filter(Boolean);
|
||||
|
||||
for (const root of searchRoots) {
|
||||
const resolved = this.findDirectoryByName(root, namedProject, 4);
|
||||
const resolved = this.findDirectoryByName(root, namedProject, 2); // Depth reduced to 2 for performance and accuracy.
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
|
||||
@@ -424,10 +528,108 @@ export class AgentExecutor {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async executeMultiAgentWorkflow(
|
||||
prompt: string,
|
||||
modelName: string,
|
||||
options: any
|
||||
) {
|
||||
if (!this.webview) return;
|
||||
this.statusBarManager.updateStatus(AgentStatus.Thinking, 'Multi-Agent Workflow Started');
|
||||
this.webview.postMessage({ type: 'streamStart' });
|
||||
|
||||
try {
|
||||
// Instantiate decoupled agents
|
||||
const planner = new PlannerAgent(modelName);
|
||||
const researcher = new ResearcherAgent(modelName);
|
||||
const writer = new WriterAgent(modelName);
|
||||
|
||||
// Prepare Context
|
||||
const activeBrain = getActiveBrainProfile();
|
||||
const brainFiles = findBrainFiles(activeBrain.localBrainPath);
|
||||
const brainContext = `Brain: ${activeBrain.name}, Files: ${brainFiles.length}`;
|
||||
|
||||
// --- Phase 1: Planner ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Planner: 전략 수립 중...' });
|
||||
const plan = await planner.execute(prompt, brainContext);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n### 📝 작업 계획 (Execution Plan)\n${plan}\n\n` });
|
||||
|
||||
// --- Phase 2: Researcher ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Researcher: 지식 검색 중...' });
|
||||
const research = await researcher.execute(plan, brainContext);
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `\n\n### 🔍 분석 결과 (Research Findings)\n*(정보 수집 및 정제 완료)*\n\n` });
|
||||
|
||||
// --- Phase 3: Writer ---
|
||||
this.webview.postMessage({ type: 'autoContinue', value: 'Writer: 보고서 작성 중...' });
|
||||
const finalReport = await writer.execute(research, prompt);
|
||||
|
||||
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) {
|
||||
const friendly = ErrorTranslator.translate(error);
|
||||
logError('Workflow failed', error);
|
||||
|
||||
// Format error using guideline-compliant UI (Red color scheme)
|
||||
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 isProjectAnalysisRequest(normalized: string): boolean {
|
||||
// Intentionally conservative: omit generic words like '조사', '설명', '파악'
|
||||
// that appear in ordinary chat and would incorrectly bypass LM Studio
|
||||
return /(분석|리뷰|설계|구조|어떤 프로그램|무슨 프로그램|제품 설명)/.test(normalized);
|
||||
// Only trigger local analysis if the intent is strictly about a project-level overview.
|
||||
// Avoid generic terms like 'analysis' or 'review' that are common in general coding chat.
|
||||
const hasProjectKeyword = /(프로젝트|project|레포|repository)/.test(normalized);
|
||||
const hasAnalysisIntent = /(전체 요약|제품 설명|어떤 프로그램|구조 파악|훑어보기)/.test(normalized);
|
||||
|
||||
return hasProjectKeyword && hasAnalysisIntent;
|
||||
}
|
||||
|
||||
private buildProjectAnalysisReply(projectPath: string): string {
|
||||
@@ -691,6 +893,13 @@ export class AgentExecutor {
|
||||
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) });
|
||||
});
|
||||
@@ -818,179 +1027,219 @@ export class AgentExecutor {
|
||||
let brainModified = false;
|
||||
let firstCreatedFile: string | undefined;
|
||||
|
||||
// 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);
|
||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
report.push(`✅ Created: ${relPath}`);
|
||||
if (!firstCreatedFile) firstCreatedFile = absPath;
|
||||
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||
} catch (err: any) { report.push(`❌ Error Creating ${relPath}: ${err.message}`); }
|
||||
}
|
||||
try {
|
||||
this.transactionManager.begin();
|
||||
|
||||
// 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)) {
|
||||
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}`);
|
||||
}
|
||||
// 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(_getBrainDir())) brainModified = true;
|
||||
} else {
|
||||
report.push(`❌ File not found: ${relPath}`);
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to create file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Editing ${relPath}: ${err.message}`); }
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
fs.unlinkSync(absPath);
|
||||
report.push(`🗑 Deleted: ${relPath}`);
|
||||
} else {
|
||||
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
||||
}
|
||||
} catch (err: any) { report.push(`❌ Error Deleting ${relPath}: ${err.message}`); }
|
||||
}
|
||||
|
||||
// Action 4: Read File
|
||||
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)";
|
||||
// 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(_getBrainDir())) brainModified = true;
|
||||
} else {
|
||||
report.push(`❌ File not found: ${relPath}`);
|
||||
}
|
||||
|
||||
report.push(`📂 Listed: ${relPath}`);
|
||||
this.chatHistory.push({ role: 'system', content: `[Result of list_files ${relPath}]\n${listing}`, internal: true });
|
||||
} catch (err: any) {
|
||||
throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
|
||||
}
|
||||
} 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 = _getBrainDir();
|
||||
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)";
|
||||
// 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 = _getBrainDir();
|
||||
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 = _getBrainDir();
|
||||
const files = findBrainFiles(brainDir);
|
||||
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
||||
|
||||
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}`); }
|
||||
}
|
||||
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}`); }
|
||||
}
|
||||
|
||||
const brainRegex = /<read_brain>([\s\S]*?)<\/read_brain>/gi;
|
||||
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
||||
const fileName = match[1].trim();
|
||||
try {
|
||||
const brainDir = _getBrainDir();
|
||||
const files = findBrainFiles(brainDir);
|
||||
// Look for direct match or path match
|
||||
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}`); }
|
||||
}
|
||||
|
||||
// Action 8: Read URL (Simple implementation)
|
||||
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();
|
||||
// Simple HTML to text-ish conversion
|
||||
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() && getSecondBrainRepo()) {
|
||||
this.syncBrain();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if (firstCreatedFile) {
|
||||
|
||||
Reference in New Issue
Block a user