feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)

This commit is contained in:
Wonseok Jung
2026-04-30 00:19:06 +09:00
parent f8a57cfbb0
commit 326672cb93
25 changed files with 5606 additions and 363 deletions
+19
View File
@@ -21,6 +21,25 @@
- Removed premature empty stream chunks that were causing UI flickering.
- Verified build stability with `v2.2.46` VSIX package.
---
## v2.8.0 (2026-04-29) - Intelligent Collaborative System Evolution
### 🤖 Multi-Agent Workflow (MAW)
- **Planner Agent:** 전략 수립 및 작업 단계 자동 설계.
- **Researcher Agent:** 지식 베이스(Second Brain) 심층 분석 및 데이터 정제.
- **Writer Agent:** 최종 보고서 형태의 고품질 답변 생성 및 통합.
- 복잡한 요청(분석, 보고서 등) 감지 시 자동으로 멀티 에이전트 모드 활성화.
### 🔮 Proactive Suggestion Engine
- **Behavioral Tracking:** 사용자 UI 체류 시간(Dwell Time) 기반 의도 감지.
- **Smart Tips:** 설정, 지식 동기화, 에이전트 선택 등 상황에 맞는 능동적 도움말 제공.
### 🎨 UX & Reliability Improvements
- **Visual Feedback:** 가이드라인에 따른 상태별 아이콘 및 피드백 일관성 강화.
- **Model Fix:** 모델 선택 및 저장 로직 안정화 완료.
- **Agent Handoff:** 각 에이전트 간 데이터 전달 및 진행 상황 시각화 개선.
---
*Date: 2026-04-25*
*Version: 2.2.46*
@@ -0,0 +1,40 @@
# Advanced Features Implementation Guide
이 문서는 ConnectAI(G1nation)의 '지능형 협업 시스템' 구현을 위한 기술 가이드라인입니다.
---
## 🤖 1. 멀티 에이전트 워크플로우 (Multi-Agent Workflow)
사용자 요청을 분해, 분석, 통합하는 3단계 협업 엔진을 구축합니다.
### A. 에이전트 역할 정의
- **Planner Agent (전략가):** 요청 분석, 검색 전략 수립, Task List 생성.
- **Researcher Agent (분석가):** 지식 베이스 데이터 추출, 신뢰도 평가, 데이터 정제.
- **Writer Agent (편집자):** 데이터 통합, 톤앤매너 적용, 최종 보고서 완성.
### B. 핸드오프(Hand-off) 흐름
1. **Planner:** Thinking Bar 활성화 및 작업 설계도 작성.
2. **Researcher:** 설계도 기반 지식 검색 및 메타데이터 확보.
3. **Writer:** 수집된 데이터를 논리적 흐름에 따라 구조화하여 최종 응답 제공 (성공 Toast 알림).
---
## 🌐 2. 모달리티 확장 및 선제적 제안 (The Frontier)
### A. 멀티모달 파이프라인
- **이미지/PDF:** OCR 및 레이아웃 분석을 통해 구조적 정보를 LLM에 주입.
- **음성:** STT 변환 및 감정 분석을 통한 뉘앙스 조절.
### B. 선제적 제안 (Proactive Suggestion)
- **행동 감지:** 사용자의 탐색 패턴(특정 컴포넌트 체류 시간 등)을 이벤트로 포착.
- **예측 제안:** 행동 패턴 기반으로 사용자가 필요로 할 기능을 미리 제시 (예: 💡 효율 증대 설정 제안).
---
## ✅ 통합 체크리스트
- [ ] FSD 기반 모듈 분리
- [ ] Planner -> Researcher -> Writer 핸드오프 로직
- [ ] 이미지/PDF 레이아웃 분석 모듈 연동
- [ ] 사용자 행동 패턴 감지기 구현
- [ ] 일관된 UX 피드백(Toast, Thinking Bar) 적용
+44
View File
@@ -0,0 +1,44 @@
# UX/UI Consistency Guidelines
이 문서는 ConnectAI(G1nation) 프로젝트의 UI 일관성과 사용자 경험을 유지하기 위한 핵심 규칙을 정의합니다. 모든 신규 기능 및 UI 수정은 이 가이드라인을 준수해야 합니다.
---
## 🎨 1. 상호작용 패턴 (Interaction Pattern)
### Rule A: 생성 및 추가 (Create/Add)
* **시각적 문법:** 핵심 생성 액션은 `+` 아이콘을 포함한 버튼 형태를 사용합니다.
* **배치 규칙:**
* **섹션 레벨:** 섹션 전체에 항목을 추가할 경우 우측 상단 또는 우측 하단에 명확한 버튼으로 배치합니다.
* **콘텐츠 레벨:** 특정 아이템 내 추가는 아이템 우측 상단에 작은 `+` 아이콘 버튼으로 고정합니다.
### Rule B: 편집 및 수정 (Edit/Modify)
* **편집 가능 여부 안내:** 수정이 불가능한 'Source of Truth' 데이터는 편집 버튼을 노출하지 않으며, 필요 시 툴팁으로 사유를 안내합니다.
* **수정 방식:** 편집 기능이 있는 경우 공통적으로 `⚙️` (설정) 또는 `⋮` (더보기) 아이콘을 사용하며, 모달(Modal) 창 또는 사이드 패널 방식을 통해 체계적으로 수정합니다.
---
## 🖌️ 2. 비주얼 가이드라인 (Visual Guidelines)
### A. 아이코노그래피 (Iconography) 통일
* **추가:** `+` (Plus)
* **편집/설정:** `⚙️` (Gear) 또는 `⋮` (Vertical Ellipsis)
* **삭제:** `🗑️` (Trash)
* **동기화:** `↻` (Refresh)
### B. 정보 계층 구조 (Information Hierarchy)
* **핵심 액션 우선:** 사용자가 가장 자주 사용하는 기능(질문 입력, 채팅 시작)이 시각적으로 가장 강조되어야 합니다.
* **그룹화:** 관련 있는 기능(AI 엔진 설정, 지식 베이스 관리, 도구 모음)은 시각적으로 그룹화하여 인지 부하를 줄입니다.
### C. 피드백 및 상태 메시지 (Feedback & State)
* **색상 코드:**
* 🟢 **성공:** 녹색 계열 (#00FF41) - Toast 알림 사용
* 🔴 **오류:** 빨간색 계열 (#ff5252) - 에러 모달 또는 명확한 배너 사용
* 🟡 **정보/진행:** 파란색/보라색 계열 - Thinking Bar 및 로딩 애니메이션 사용
---
## 🚀 3. 체크리스트
- [ ] 새로운 버튼이 기존의 아이콘 문법을 따르는가?
- [ ] 생성/편집 액션이 정의된 위치에 배치되었는가?
- [ ] 상태 변화에 대해 일관된 색상과 방식으로 피드백을 주는가?
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
};
+3812 -2
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -2,7 +2,7 @@
"name": "g1nation",
"displayName": "G1nation",
"description": "100% local AI coding agent for VS Code. Create files, edit code, run commands, and work offline with Ollama or LM Studio.",
"version": "2.2.67",
"version": "2.12.0",
"publisher": "connectailab",
"license": "MIT",
"icon": "assets/icon.png",
@@ -95,6 +95,11 @@
"configuration": {
"title": "G1nation",
"properties": {
"g1nation.multiAgentEnabled": {
"type": "boolean",
"default": true,
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
},
"g1nation.ollamaUrl": {
"type": "string",
"default": "http://127.0.0.1:11434",
@@ -169,6 +174,11 @@
"type": "number",
"default": 50,
"description": "Maximum autonomous steps the agent can take per request. Default: 50"
},
"g1nation.dryRun": {
"type": "boolean",
"default": false,
"description": "If enabled, the agent will ask for approval before committing any file changes."
}
}
}
@@ -180,11 +190,14 @@
"pretest": "npm run compile"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/marked": "^5.0.2",
"@types/node": "18.x",
"@types/vscode": "^1.80.0",
"@vercel/ncc": "^0.38.4",
"esbuild": "^0.28.0",
"jest": "^29.7.0",
"ts-jest": "^29.4.9",
"typescript": "^5.1.3"
},
"dependencies": {
+428 -179
View File
@@ -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) {
+55
View File
@@ -0,0 +1,55 @@
import * as vscode from 'vscode';
import { getConfig } from '../config';
export abstract class BaseAgent {
constructor(protected readonly modelName: string) {}
protected async callLLM(persona: string, prompt: string): Promise<string> {
const { ollamaUrl } = getConfig();
const messages = [
{ role: 'system', content: persona },
{ role: 'user', content: prompt }
];
// API 호출 로직 (Streaming 생략하고 결과만 반환하는 헬퍼)
const response = await fetch(`${ollamaUrl}/api/chat`, {
method: 'POST',
body: JSON.stringify({
model: this.modelName,
messages,
stream: false,
options: { temperature: 0.3 }
})
});
if (!response.ok) {
throw new Error(`Agent API Error: ${response.statusText}`);
}
const data = await response.json() as any;
return data.message?.content || data.choices?.[0]?.message?.content || '';
}
abstract execute(input: string, context?: string): Promise<string>;
}
export class PlannerAgent extends BaseAgent {
private readonly persona = `You are the [Planner Agent]. Analyze the request and output a structured <plan>.`;
async execute(input: string, brainContext?: string): Promise<string> {
return this.callLLM(this.persona, `Request: ${input}\nContext: ${brainContext}`);
}
}
export class ResearcherAgent extends BaseAgent {
private readonly persona = `You are the [Researcher Agent]. Gather facts based on the plan.`;
async execute(input: string, brainContext?: string): Promise<string> {
return this.callLLM(this.persona, `Plan: ${input}\nContext: ${brainContext}`);
}
}
export class WriterAgent extends BaseAgent {
private readonly persona = `You are the [Writer Agent]. Synthesize research into a final report.`;
async execute(input: string, originalRequest?: string): Promise<string> {
return this.callLLM(this.persona, `Data: ${input}\nOriginal Request: ${originalRequest}`);
}
}
+2 -1
View File
@@ -3,16 +3,17 @@ import * as fs from 'fs';
import * as path from 'path';
// axios removed
import {
getConfig,
_getBrainDir,
_isBrainDirExplicitlySet,
findBrainFiles,
buildApiUrl,
logError,
logInfo,
logWarn,
resolveEngine,
summarizeText
} from './utils';
import { getConfig } from './config';
export interface BridgeInterface {
injectSystemMessage(msg: string): void;
+120 -64
View File
@@ -1,97 +1,153 @@
/**
* ============================================================
* Centralized Configuration (중앙 집중식 설정 관리)
*
* 모든 환경 설정(모델 이름, API 엔드포인트, 타임아웃, 보안 정책 등)
* 을 한 곳에서 관리합니다. Single Source of Truth 원칙 적용.
* ============================================================
*/
import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
// ─── VS Code 설정에서 읽어오는 값 ───
export function getConfig(): IAgentConfig {
const cfg = vscode.workspace.getConfiguration('g1nation');
return {
ollamaBase: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'),
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
maxTreeFiles: cfg.get<number>('maxTreeFiles', 200),
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
localBrainPath: cfg.get<string>('localBrainPath', '')
};
// ─── 브레인 프로필 인터페이스 ───
export interface BrainProfile {
id: string;
name: string;
localBrainPath: string;
secondBrainRepo?: string;
description?: string;
}
// ─── 에이전트 설정 인터페이스 ───
// ─── 에이전트 설정 인터페이스 (통합 버전) ───
export interface IAgentConfig {
ollamaBase: string;
ollamaUrl: string;
defaultModel: string;
maxTreeFiles: number;
timeout: number;
localBrainPath: string;
secondBrainRepo: string;
brainProfiles: BrainProfile[];
activeBrainId: string;
maxContextSize: number;
maxAutoSteps: number;
dryRun: boolean;
multiAgentEnabled: boolean;
}
// ─── 두뇌 폴더 경로 유틸리티 ───
export function getBrainDir(): string {
const { localBrainPath } = getConfig();
if (localBrainPath && localBrainPath.trim() !== '') {
if (localBrainPath.startsWith('~/')) {
return path.join(os.homedir(), localBrainPath.substring(2));
}
return localBrainPath.trim();
// ─── 경로 정규화 유틸리티 ───
function normalizePath(p: string): string {
if (!p) return p;
if (p.startsWith('~/')) {
return path.join(os.homedir(), p.substring(2));
}
return path.join(os.homedir(), '.g1nation-brain');
return p.trim();
}
export function isBrainDirExplicitlySet(): boolean {
const { localBrainPath } = getConfig();
return !!(localBrainPath && localBrainPath.trim() !== '');
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
if (!raw) return null;
const localBrainPath = normalizePath(raw.localBrainPath || '');
if (!localBrainPath) return null;
return {
id: (raw.id || `brain-${fallbackIndex + 1}`).trim(),
name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(),
localBrainPath,
secondBrainRepo: (raw.secondBrainRepo || '').trim(),
description: (raw.description || '').trim()
};
}
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
export function getConfig(): IAgentConfig {
const cfg = vscode.workspace.getConfiguration('g1nation');
// 브레인 프로필 로직 (utils.ts에서 이관)
const legacyBrainPath = cfg.get<string>('localBrainPath', '');
const legacyBrainRepo = cfg.get<string>('secondBrainRepo', '');
const configuredProfiles = cfg.get<Partial<BrainProfile>[]>('brainProfiles', []);
const profiles = configuredProfiles
.map((profile, index) => toBrainProfile(profile, index))
.filter((profile): profile is BrainProfile => !!profile);
if (profiles.length === 0) {
const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain');
profiles.push({
id: 'default-brain',
name: 'Local Brain',
localBrainPath: fallbackPath,
secondBrainRepo: legacyBrainRepo.trim(),
description: legacyBrainPath
? 'Migrated from your existing localBrainPath setting'
: 'Auto-created local knowledge folder.'
});
}
const activeBrainId = cfg.get<string>('activeBrainId', profiles[0].id) || profiles[0].id;
const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0];
const rationaleProtocol = `
3. Always explain your thought process using the <rationale> tag BEFORE performing any actions. Use the following structure:
<rationale>
[PROBLEM] Description of the issue or need found in the context.
[GOAL] What you intend to achieve with your proposed changes.
[REASONING] Detailed logical basis for choosing specific actions or architecture.
</rationale>
`;
return {
ollamaUrl: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434',
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b') || 'gemma4:e2b',
maxTreeFiles: 200,
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
localBrainPath: activeBrain.localBrainPath,
secondBrainRepo: activeBrain.secondBrainRepo || '',
brainProfiles: profiles,
activeBrainId: activeBrain.id,
maxContextSize: cfg.get<number>('maxContextSize', 12000),
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50),
dryRun: cfg.get<boolean>('dryRun', false),
multiAgentEnabled: cfg.get<boolean>('multiAgentEnabled', true)
};
}
/**
* Config Validator: Validates the current configuration.
*/
export function validateConfig(): { valid: boolean; errors: string[] } {
const config = getConfig();
const errors: string[] = [];
// 1. Ollama URL Validation
try {
new URL(config.ollamaUrl);
} catch (e) {
errors.push(`Invalid Ollama URL: ${config.ollamaUrl}`);
}
// 2. Brain Path Validation
if (config.localBrainPath && !fs.existsSync(config.localBrainPath)) {
errors.push(`Brain path does not exist: ${config.localBrainPath}`);
}
return {
valid: errors.length === 0,
errors
};
}
// ─── 보안 정책 (Security Policy) ───
export const SECURITY_POLICY = {
// 허용된 터미널 명령어 프리픽스 (Whitelist)
allowedCommandPrefixes: [
'npm', 'yarn', 'pnpm', 'npx',
'node', 'ts-node',
'git',
'python', 'python3', 'pip', 'pip3',
'docker', 'docker-compose',
'ls', 'dir', 'cat', 'type',
'echo', 'print',
'cargo', 'go', 'rustc',
'java', 'javac', 'mvn', 'gradle',
'flutter', 'dart', 'pub',
'webpack', 'vite', 'esbuild', 'parcel',
'jest', 'mocha', 'vitest', 'cypress',
'tsc', 'vue-tsc',
'npm', 'yarn', 'pnpm', 'npx', 'node', 'ts-node', 'git', 'python', 'python3', 'pip', 'pip3',
'docker', 'docker-compose', 'ls', 'dir', 'cat', 'type', 'echo', 'print', 'cargo', 'go', 'rustc',
'java', 'javac', 'mvn', 'gradle', 'flutter', 'dart', 'pub', 'webpack', 'vite', 'esbuild', 'parcel',
'jest', 'mocha', 'vitest', 'cypress', 'tsc', 'vue-tsc',
],
// 절대 실행 금지 명령어 (Blacklist)
forbiddenCommands: [
'rm -rf', 'rm-rf', 'del /f', 'format',
'mkfs', 'dd if=', ':(){ :|:& };:',
'wget http', 'curl http', 'sudo',
'chmod 777', 'chown root',
'rm -rf', 'rm-rf', 'del /f', 'format', 'mkfs', 'dd if=', ':(){ :|:& };:',
'wget http', 'curl http', 'sudo', 'chmod 777', 'chown root',
],
// 민감한 파일 패턴 (파일 생성/수정 시 경고)
sensitiveFilePatterns: [
'.env', '.env.*',
'id_rsa', 'id_ed25519',
'.gitconfig', '.npmrc', '.pypirc',
'.env', '.env.*', 'id_rsa', 'id_ed25519', '.gitconfig', '.npmrc', '.pypirc',
'credentials.json', 'service-account.json',
],
// 파일 생성 시 최대 크기 (bytes)
maxFileSize: 10 * 1024 * 1024, // 10MB
// 맥락 파일 최대 개수
maxFileSize: 10 * 1024 * 1024,
maxContextFiles: 200,
};
// ─── 시스템 프롬프트 상수 ───
export const MAX_CONTEXT_SIZE = 12_000;
export const EXCLUDED_DIRS = new Set([
+28
View File
@@ -0,0 +1,28 @@
import { logWarn } from '../utils';
/**
* ConflictResolver: Analyzes agent actions for logical contradictions
* or goal conflicts (e.g., Security vs. Performance).
*/
export class ConflictResolver {
/**
* Analyzes proposed actions and returns a warning message if conflicts are found.
*/
public static analyze(actions: any[]): string | null {
// 1. Resource Conflict: Multiple edits to sensitive core files
const securityFiles = actions.filter(a => a.path && (a.path.includes('auth') || a.path.includes('security') || a.path.includes('config')));
const performanceChanges = actions.filter(a => a.content && (a.content.includes('async') || a.content.includes('parallel') || a.content.includes('cache')));
if (securityFiles.length > 0 && performanceChanges.length > 0) {
return "⚠️ Goal Conflict Detected: You are attempting to modify security-sensitive files while introducing performance optimizations (async/parallel). This might introduce race conditions or security vulnerabilities. Should I proceed with both, or prioritize Security?";
}
// 2. Structural Conflict: Modifying both interface and implementation in a way that might break contracts
const interfaces = actions.filter(a => a.path && a.path.endsWith('.d.ts') || a.path.includes('interface'));
if (interfaces.length > 1 && actions.length > 10) {
return "⚠️ Structural Complexity Warning: This task involves massive changes to both interfaces and multiple implementations. This may lead to unexpected side effects. Would you like a step-by-step review?";
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface UserFriendlyError {
title: string;
message: string;
action: string;
}
export class ErrorTranslator {
public static translate(error: any): UserFriendlyError {
const msg = String(error.message || error).toLowerCase();
if (msg.includes('fetch') || msg.includes('network') || msg.includes('econnrefused')) {
return {
title: '🌐 연결 오류 (Connection Error)',
message: 'AI 엔진(LM Studio 또는 Ollama)에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.',
action: '서버를 실행한 후 "Refresh" 버튼을 눌러주세요.'
};
}
if (msg.includes('timeout')) {
return {
title: '⏱️ 응답 시간 초과 (Timeout)',
message: 'AI가 답변을 준비하는 데 너무 오래 걸리고 있습니다.',
action: '설정에서 Timeout 시간을 늘리거나, 더 작은 범위로 질문해보세요.'
};
}
if (msg.includes('model not found') || msg.includes('404')) {
return {
title: '🤖 모델 인식 불가 (Model Not Found)',
message: '선택하신 모델을 시스템에서 찾을 수 없습니다.',
action: '상단 드롭다운에서 다른 모델을 선택하거나 모델 이름을 확인해주세요.'
};
}
return {
title: '⚠️ 알 수 없는 오류',
message: '작업 중 예상치 못한 문제가 발생했습니다.',
action: '로그를 확인하거나 확장을 재시작해보세요.'
};
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* g1nation Custom Error Classes
*/
export abstract class G1Error extends Error {
constructor(public message: string, public details?: any) {
super(message);
this.name = this.constructor.name;
}
abstract getTypeCode(): string;
}
export class AgentExecutionError extends G1Error {
getTypeCode() { return 'AGENT_EXECUTION_ERROR'; }
}
export class FileSystemError extends G1Error {
constructor(message: string, public path: string, details?: any) {
super(message, details);
}
getTypeCode() { return 'FILE_SYSTEM_ERROR'; }
}
export class APICommunicationError extends G1Error {
constructor(message: string, public engine: string, public status?: number, details?: any) {
super(message, details);
}
getTypeCode() { return 'API_COMMUNICATION_ERROR'; }
}
export class SecurityValidationError extends G1Error {
getTypeCode() { return 'SECURITY_VALIDATION_ERROR'; }
}
export class TransactionError extends G1Error {
getTypeCode() { return 'TRANSACTION_ERROR'; }
}
+59
View File
@@ -0,0 +1,59 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import { getConfig } from '../config';
import { logInfo, logWarn, logError } from '../utils';
/**
* HealthCheckMonitor: Periodically monitors the environment
* (Ollama, Disk, API) to ensure the agent stays functional.
*/
export class HealthCheckMonitor {
public static async runAllChecks(): Promise<{ ok: boolean; reports: string[] }> {
const reports: string[] = [];
const config = getConfig();
// 1. Ollama Connectivity Check
try {
const res = await fetch(`${config.ollamaUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
if (res.ok) {
logInfo('Health Check: Ollama connectivity OK.');
} else {
reports.push('⚠️ AI Server (Ollama) is reachable but returned an error.');
}
} catch {
reports.push('❌ AI Server (Ollama) is NOT reachable. Please check if it is running.');
}
// 2. Workspace Validation
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
reports.push('⚠️ No workspace folder open. Agent capabilities will be limited.');
}
// 3. Simple Disk/Permissions Check
try {
const testFile = workspaceFolders ? vscode.Uri.joinPath(workspaceFolders[0].uri, '.g1-health-check') : null;
if (testFile) {
await vscode.workspace.fs.writeFile(testFile, Buffer.from('ok'));
await vscode.workspace.fs.delete(testFile);
logInfo('Health Check: Write permissions OK.');
}
} catch {
reports.push('❌ Write permissions denied in the current workspace.');
}
if (reports.length > 0) {
logWarn(`Health Check Warnings: ${reports.join(' | ')}`);
vscode.window.showWarningMessage(`ConnectAI Health Warning: ${reports[0]}`);
}
return {
ok: reports.length === 0,
reports
};
}
public static startInterval(ms: number = 300000) { // Default 5 mins
setInterval(() => this.runAllChecks(), ms);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { logInfo } from '../utils';
/**
* AsyncLockManager: Prevents race conditions by ensuring only one task
* can access a specific resource (e.g., a file path) at a time.
*/
export class AsyncLockManager {
private locks: Map<string, Promise<void>> = new Map();
/**
* Acquires a lock for a specific resource.
* If the resource is already locked, it waits until the previous task finishes.
*/
public async acquire(resourceId: string): Promise<() => void> {
const previousLock = this.locks.get(resourceId) || Promise.resolve();
let release: () => void;
const newLock = new Promise<void>((resolve) => {
release = resolve;
});
this.locks.set(resourceId, previousLock.then(() => newLock));
await previousLock;
logInfo(`Lock acquired for: ${resourceId}`);
return () => {
logInfo(`Lock released for: ${resourceId}`);
release();
if (this.locks.get(resourceId) === newLock) {
this.locks.delete(resourceId);
}
};
}
}
// Export as a singleton for the entire agent process
export const lockManager = new AsyncLockManager();
+53
View File
@@ -0,0 +1,53 @@
import { logInfo, logError } from '../utils';
/**
* ActionQueueManager: Manages large-scale tasks by processing them
* sequentially to prevent resource exhaustion and I/O bottlenecks.
*/
export class ActionQueueManager {
private queue: (() => Promise<void>)[] = [];
private isProcessing: boolean = false;
/**
* Adds a task to the queue.
*/
public async enqueue<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
}
});
this.processNext();
});
}
private async processNext() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const task = this.queue.shift();
if (task) {
try {
// Add a micro-delay to allow system breathing room between heavy I/O
await new Promise(r => setTimeout(r, 50));
await task();
} catch (error) {
logError('Task in queue failed:', error);
} finally {
this.isProcessing = false;
this.processNext();
}
}
}
public getPendingCount(): number {
return this.queue.length;
}
}
export const actionQueue = new ActionQueueManager();
+88
View File
@@ -0,0 +1,88 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { ChatMessage } from '../agent';
import { logInfo, logError } from '../utils';
interface SessionData {
taskId: string;
history: ChatMessage[];
lastActionStr?: string;
timestamp: number;
}
export class SessionManager {
private sessionDir: string;
constructor(private context: vscode.ExtensionContext) {
// Use globalStorageUri for persistence across workspace restarts
this.sessionDir = path.join(this.context.globalStorageUri.fsPath, 'sessions');
this.ensureDir(this.sessionDir);
}
private ensureDir(dir: string) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Saves the current session state.
*/
public async saveSession(taskId: string, history: ChatMessage[], lastActionStr?: string) {
const sessionData: SessionData = {
taskId,
history,
lastActionStr,
timestamp: Date.now()
};
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
try {
fs.writeFileSync(filePath, JSON.stringify(sessionData, null, 2), 'utf-8');
// Also store the last active taskId in globalState
await this.context.globalState.update('activeTaskId', taskId);
} catch (error: any) {
logError('Failed to save session state', error);
}
}
/**
* Loads a specific session by taskId.
*/
public loadSession(taskId: string): SessionData | null {
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
if (!fs.existsSync(filePath)) return null;
try {
const content = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(content) as SessionData;
} catch (error: any) {
logError(`Failed to load session for taskId: ${taskId}`, error);
return null;
}
}
/**
* Loads the last active session.
*/
public loadLastActiveSession(): SessionData | null {
const lastTaskId = this.context.globalState.get<string>('activeTaskId');
if (!lastTaskId) return null;
return this.loadSession(lastTaskId);
}
/**
* Clears a specific session.
*/
public clearSession(taskId: string) {
const filePath = path.join(this.sessionDir, `session_${this.sanitizeFilename(taskId)}.json`);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
private sanitizeFilename(name: string): string {
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
}
}
+57
View File
@@ -0,0 +1,57 @@
import * as vscode from 'vscode';
export enum AgentStatus {
Idle = 'Idle',
Thinking = 'Thinking...',
Executing = 'Executing Actions...',
Error = 'Error',
Success = 'Success'
}
export class StatusBarManager {
private statusBarItem: vscode.StatusBarItem;
constructor() {
this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
this.statusBarItem.command = 'g1nation.focusInput';
this.updateStatus(AgentStatus.Idle);
this.statusBarItem.show();
}
public updateStatus(status: AgentStatus, detail?: string) {
let icon = '';
switch (status) {
case AgentStatus.Idle:
icon = '$(pass)';
this.statusBarItem.backgroundColor = undefined;
break;
case AgentStatus.Thinking:
icon = '$(sync~spin)';
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
break;
case AgentStatus.Executing:
icon = '$(gear~spin)';
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
break;
case AgentStatus.Error:
icon = '$(error)';
this.statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
break;
case AgentStatus.Success:
icon = '$(check)';
this.statusBarItem.backgroundColor = undefined;
break;
}
this.statusBarItem.text = `${icon} G1nation: ${status}`;
this.statusBarItem.tooltip = detail || `Current State: ${status}`;
if (status === AgentStatus.Success || status === AgentStatus.Error) {
setTimeout(() => this.updateStatus(AgentStatus.Idle), 5000);
}
}
public dispose() {
this.statusBarItem.dispose();
}
}
+127
View File
@@ -0,0 +1,127 @@
import * as fs from 'fs';
import * as path from 'path';
import { FileSystemError, TransactionError } from './errors';
import { logInfo, logError } from '../utils';
interface BackupEntry {
path: string;
type: 'modified' | 'created' | 'deleted';
originalContent?: string;
}
export class TransactionManager {
private backups: Map<string, BackupEntry> = new Map();
private externalVerifications: Map<string, boolean> = new Map();
private isTransactionActive: boolean = false;
constructor() {}
/**
* Starts a new transaction.
*/
public begin() {
if (this.isTransactionActive) {
throw new TransactionError('A transaction is already in progress.');
}
this.backups.clear();
this.externalVerifications.clear();
this.isTransactionActive = true;
logInfo('Transaction started.');
}
/**
* Records the success of an external action (e.g., API call).
*/
public recordExternalAction(actionId: string, success: boolean) {
if (!this.isTransactionActive) return;
this.externalVerifications.set(actionId, success);
logInfo(`External action recorded: ${actionId} (success: ${success})`);
}
/**
* Checks if all recorded external actions were successful.
*/
public isFullyVerified(): boolean {
for (const success of this.externalVerifications.values()) {
if (!success) return false;
}
return true;
}
/**
* Records a file state before modification.
*/
public async record(filePath: string) {
if (!this.isTransactionActive) return;
if (this.backups.has(filePath)) return; // Already backed up in this transaction
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
this.backups.set(filePath, {
path: filePath,
type: 'modified',
originalContent: content
});
} else {
this.backups.set(filePath, {
path: filePath,
type: 'created'
});
}
} catch (error: any) {
throw new FileSystemError(`Failed to backup file: ${error.message}`, filePath, error);
}
}
/**
* Commits the transaction, clearing all backups.
*/
public commit() {
if (!this.isTransactionActive) return;
this.backups.clear();
this.isTransactionActive = false;
logInfo('Transaction committed successfully.');
}
/**
* Rolls back all changes made during the transaction.
*/
public rollback() {
if (!this.isTransactionActive) return;
logInfo(`Rolling back ${this.backups.size} changes...`);
for (const entry of this.backups.values()) {
try {
if (entry.type === 'created') {
if (fs.existsSync(entry.path)) {
fs.unlinkSync(entry.path);
logInfo(`Rollback: Deleted created file ${entry.path}`);
}
} else if (entry.type === 'modified' || entry.type === 'deleted') {
if (entry.originalContent !== undefined) {
const dir = path.dirname(entry.path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(entry.path, entry.originalContent, 'utf-8');
logInfo(`Rollback: Restored file ${entry.path}`);
}
}
} catch (error: any) {
logError(`Failed to rollback change for ${entry.path}`, error);
}
}
this.backups.clear();
this.isTransactionActive = false;
logInfo('Transaction rollback completed.');
}
public isActive(): boolean {
return this.isTransactionActive;
}
}
// Export a singleton instance if needed, or instantiate per AgentExecutor
+14 -2
View File
@@ -3,7 +3,6 @@ import * as fs from 'fs';
import * as path from 'path';
// axios removed in favor of native fetch
import {
getConfig,
_getBrainDir,
_isBrainDirExplicitlySet,
findBrainFiles,
@@ -12,15 +11,28 @@ import {
logError,
logInfo
} from './utils';
import { getConfig, validateConfig } from './config';
import { AgentExecutor } from './agent';
import { BridgeServer } from './bridge';
import { SidebarChatProvider } from './sidebarProvider';
import { HealthCheckMonitor } from './core/health';
/**
* G1nation Extension Entry Point
*/
export async function activate(context: vscode.ExtensionContext) {
logInfo('Connect AI extension activated.');
logInfo('ConnectAI activating...');
// Start Environment Health Monitoring
HealthCheckMonitor.runAllChecks();
HealthCheckMonitor.startInterval(600000); // Check every 10 mins
// 0. Validate Configuration
const validation = validateConfig();
if (!validation.valid) {
vscode.window.showErrorMessage(`G1nation Configuration Error: ${validation.errors.join(' ')}`);
logError('Configuration validation failed.', { errors: validation.errors });
}
// 1. Ensure Brain Directory
await _ensureBrainDir(context);
+381 -15
View File
@@ -2,7 +2,6 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import {
getConfig,
_getBrainDir,
findBrainFiles,
buildApiUrl,
@@ -13,6 +12,7 @@ import {
resolveEngine,
summarizeText
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
@@ -88,8 +88,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
await this._sendBrainProfiles();
await this._sendSessionList();
await this._sendModels();
await this._sendConfig();
await this._restoreActiveSessionIntoView();
break;
case 'toggleMultiAgent':
await vscode.workspace.getConfiguration('g1nation').update('multiAgentEnabled', data.value, vscode.ConfigurationTarget.Global);
break;
case 'getModels':
await this._sendModels();
break;
@@ -128,6 +132,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
await this.syncBrain();
await this._sendBrainStatus();
break;
case 'addMessage':
this._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
break;
case 'addBrain':
await this._addBrainProfile();
break;
@@ -143,6 +150,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
case 'refreshModels':
await this._sendModels();
break;
case 'model':
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', data.value, vscode.ConfigurationTarget.Global);
logInfo(`Default model updated to: ${data.value}`);
break;
case 'proactiveTrigger':
await this._handleProactiveSuggestion(data.context);
break;
case 'exportResponse':
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
const defaultPath = path.join(workspacePath, 'g1_response.md');
@@ -155,6 +169,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
}
break;
case 'approveAction':
await this._agent.approveTransaction();
break;
case 'rejectAction':
await this._agent.rejectTransaction();
break;
}
});
}
@@ -378,6 +398,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
}
private async _sendConfig() {
if (!this._view) return;
const config = getConfig();
this._view.webview.postMessage({
type: 'configUpdate',
value: {
multiAgentEnabled: config.multiAgentEnabled
}
});
}
private async _sendBrainStatus() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
@@ -631,6 +662,29 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
}
private async _handleProactiveSuggestion(context: string) {
if (!this._view) return;
let suggestion = '';
switch (context) {
case 'settings_exploration':
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
break;
case 'brain_sync_exploration':
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
break;
case 'agent_selection_exploration':
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
break;
default:
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
}
this._view.webview.postMessage({ type: 'streamStart' });
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
this._view.webview.postMessage({ type: 'streamEnd' });
}
private async _createAgent() {
const name = await vscode.window.showInputBox({
prompt: 'Name of the new Agent (e.g., frontend_expert)',
@@ -891,8 +945,24 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
.msg-head { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 11px; color: var(--text-dim); }
.av { width: 22px; height: 22px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; }
.av-user { background: var(--border); color: var(--text-primary); }
.av-ai { background: var(--accent); color: #fff; }
.icon-btn:hover { background: var(--border); color: var(--text-bright); }
.icon-btn.active { color: var(--accent); border-color: var(--accent); background: rgba(187, 134, 252, 0.1); }
/* Tooltip System */
[data-tooltip] { position: relative; }
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute; bottom: -30px; left: 50%; transform: translateX(-50%);
background: #333; color: #fff; padding: 4px 8px; border-radius: 4px;
font-size: 10px; white-space: nowrap; opacity: 0; pointer-events: none;
transition: all 0.2s ease; z-index: 100; box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
[data-tooltip]:hover::after { opacity: 1; bottom: -35px; }
.header-controls { display: flex; gap: 8px; margin-left: auto; }
#promptInput::placeholder { color: var(--accent); opacity: 0.6; font-weight: 500; }
.msg-body {
padding-left: 30px;
@@ -997,11 +1067,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
/* --- Overlays & Others --- */
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
.thinking-bar.active::after {
content: ''; position: absolute; top: 0; left: -40%; width: 40%; height: 100%;
background: linear-gradient(90deg, transparent, var(--accent), transparent); animation: think 1.5s infinite;
.thinking-bar.active {
display: block;
background: linear-gradient(90deg, transparent, #2196f3, #bb86fc, transparent);
background-size: 200% 100%;
animation: thinking 1.5s infinite linear;
}
@keyframes think { 0% { left: -40%; } 100% { left: 100%; } }
@keyframes thinking { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.history-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8);
@@ -1025,6 +1097,140 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
margin-bottom: 10px; cursor: pointer; transition: 0.2s;
}
.history-item:hover { border-color: var(--accent); background: var(--accent-glow); }
/* --- Approval UI --- */
.approval-box {
display: flex;
flex-direction: column;
gap: 12px;
margin: 15px 0;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-bright);
border-radius: 12px;
animation: msgIn 0.3s ease-out;
}
.approval-title { font-weight: 700; color: var(--accent); font-size: 12px; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; }
.approval-btns { display: flex; gap: 10px; }
.btn-approve { flex: 1; background: var(--success); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; }
.btn-approve:hover { filter: brightness(1.1); transform: translateY(-1px); }
.btn-reject { flex: 1; background: var(--error); color: white; border: none; padding: 10px; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 12px; transition: 0.2s; }
.btn-reject:hover { filter: brightness(1.1); transform: translateY(-1px); }
/* --- Physics & Micro-interactions --- */
button {
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
outline: none;
}
button:active {
transform: scale(0.96);
filter: brightness(0.9);
}
/* --- Hierarchical Grouping --- */
.input-group {
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
padding: 8px;
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
display: flex;
gap: 8px;
}
/* --- Storytelling Stepper --- */
.stepper-container {
display: none;
margin: 12px 16px;
padding: 12px;
background: rgba(var(--accent-rgb), 0.05);
border-radius: 8px;
border: 1px solid rgba(var(--accent-rgb), 0.1);
animation: slideIn 0.3s ease-out;
}
.stepper-container.active { display: block; }
.steps {
display: flex;
justify-content: space-between;
position: relative;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
transition: 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.step.active .step-dot {
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
transform: scale(1.5);
}
.step.complete .step-dot {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.step-label {
font-size: 9px;
color: var(--text-dim);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.step.active .step-label { color: var(--accent); }
.step.complete .step-label { color: var(--success); }
/* --- Rationale View (Thought Process) --- */
.rationale-container {
margin: 12px 0;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid var(--accent);
border-radius: 4px 12px 12px 4px;
font-size: 12px;
line-height: 1.5;
animation: slideIn 0.3s ease-out;
backdrop-filter: blur(10px);
}
.rationale-header {
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.rationale-section {
margin-bottom: 10px;
}
.rationale-label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
color: var(--text-bright);
margin-bottom: 4px;
font-size: 11px;
}
.rationale-content {
color: var(--text-dim);
padding-left: 20px;
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
</head>
<body>
@@ -1043,9 +1249,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<select id="agentSel" title="Select Agentic Skill"></select>
<button class="icon-btn" id="editAgentBtn" title="Edit Agent Skill">📝</button>
<button class="icon-btn" id="addAgentBtn" title="Create Agent">+</button>
<button class="icon-btn" id="internetBtn" title="Internet Access">🌐</button>
<button class="icon-btn" id="brainBtn" title="Sync Knowledge">🧠</button>
<button class="icon-btn" id="historyBtn" title="History">📜</button>
<button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">🤖</button>
<button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">🌐</button>
<button class="icon-btn" id="brainBtn" data-tooltip="Sync Knowledge">🧠</button>
<button class="icon-btn" id="historyBtn" data-tooltip="View History">📜</button>
</div>
</div>
@@ -1059,6 +1266,15 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<div class="thinking-bar" id="thinkingBar"></div>
<div id="stepper" class="stepper-container">
<div class="steps">
<div class="step" id="step-analyze"><div class="step-dot"></div><div class="step-label">Analyze</div></div>
<div class="step" id="step-plan"><div class="step-dot"></div><div class="step-label">Plan</div></div>
<div class="step" id="step-execute"><div class="step-dot"></div><div class="step-label">Execute</div></div>
<div class="step" id="step-verify"><div class="step-dot"></div><div class="step-label">Verify</div></div>
</div>
</div>
<div class="chat" id="chat">
<div class="welcome">
<div class="welcome-logo">✦</div>
@@ -1088,6 +1304,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
<button id="sendBtn" class="send-btn">Send</button>
</div>
</div>
<div class="input-group">
<button class="action-btn" style="flex:1" id="inputNewChatBtn">New Chat</button>
<button class="action-btn" style="flex:1" id="inputSyncBtn">Sync Knowledge</button>
</div>
</div>
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
</div>
@@ -1098,6 +1319,52 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const sendBtn = document.getElementById('sendBtn');
const thinkingBar = document.getElementById('thinkingBar');
const statusLabel = document.getElementById('statusLabel');
const stepper = document.getElementById('stepper');
// --- Sound Manager ---
const Sound = {
ctx: null,
init() { if (!this.ctx) this.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
play(freq, type, dur) {
try {
this.init();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + dur);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + dur);
} catch(e) {}
},
success() { this.play(880, 'sine', 0.1); setTimeout(() => this.play(1109, 'sine', 0.15), 80); },
warn() { this.play(440, 'triangle', 0.3); }
};
function setStep(stepId, state = 'active') {
stepper.classList.add('active');
const step = document.getElementById('step-' + stepId);
if (step) {
if (state === 'active') {
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
step.classList.add('active');
} else if (state === 'complete') {
step.classList.remove('active');
step.classList.add('complete');
}
}
}
function resetStepper() {
stepper.classList.remove('active');
document.querySelectorAll('.step').forEach(s => {
s.classList.remove('active');
s.classList.remove('complete');
});
}
const modelSel = document.getElementById('modelSel');
const brainSel = document.getElementById('brainSel');
const historyOverlay = document.getElementById('historyOverlay');
@@ -1133,16 +1400,45 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
document.body.removeChild(textarea);
}
window.approve = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'approveAction' });
};
window.reject = () => {
const box = document.querySelector('.approval-box');
if (box) box.remove();
vscode.postMessage({ type: 'rejectAction' });
};
function exportToMD(text) {
vscode.postMessage({ type: 'exportResponse', text: text });
}
function addMsg(text, role) {
function addMsg(text, role, rationale) {
const isUser = role === 'user';
const msgEl = document.createElement('div');
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
msgEl._raw = text;
// If rationale exists and it's an AI message, add the Rationale View
if (!isUser && rationale && (rationale.problem || rationale.goal || rationale.reasoning)) {
const ratDiv = document.createElement('div');
ratDiv.className = 'rationale-container';
let ratHtml = '<div class="rationale-header"><span>🧠</span> Thought Process</div>';
if (rationale.problem) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>⚠️</span> Problem</div><div class="rationale-content">' + rationale.problem + '</div></div>';
}
if (rationale.goal) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>💡</span> Goal</div><div class="rationale-content">' + rationale.goal + '</div></div>';
}
if (rationale.reasoning) {
ratHtml += '<div class="rationale-section"><div class="rationale-label"><span>✅</span> Rationale</div><div class="rationale-content">' + rationale.reasoning + '</div></div>';
}
ratDiv.innerHTML = ratHtml;
chat.appendChild(ratDiv);
}
const head = document.createElement('div');
head.className = 'msg-head';
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> G1nation';
@@ -1179,6 +1475,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
window.addEventListener('message', e => {
const msg = e.data;
switch(msg.type) {
case 'addMessage':
addMsg(msg.value, msg.role, msg.rationale);
break;
case 'streamStart':
thinkingBar.classList.remove('active');
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
@@ -1196,6 +1495,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
case 'streamEnd':
if (streamBody) streamBody.classList.remove('stream-active');
streamBody = null; sendBtn.disabled = false;
resetStepper();
Sound.success();
break;
case 'restoreHistory':
case 'sessionLoaded':
@@ -1204,7 +1505,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (history && history.length > 0) {
chat.innerHTML = '';
history.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user'));
history.forEach(m => addMsg(m.content, m.role === 'assistant' ? 'assistant' : 'user', m.rationale));
}
if (historyData.negativePrompt !== undefined) {
negativePrompt.value = historyData.negativePrompt;
@@ -1224,6 +1525,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
if (m === msg.value.selected) o.selected = true;
modelSel.appendChild(o);
});
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
break;
case 'brainProfiles':
@@ -1251,6 +1553,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
break;
case 'autoContinue':
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
if (msg.value.includes('Analyzing')) setStep('analyze');
if (msg.value.includes('Planning')) setStep('plan');
if (msg.value.includes('Executing')) setStep('execute');
setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
break;
case 'agentsList':
@@ -1272,6 +1577,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
addMsg(msg.value, 'error');
break;
case 'requiresApproval':
const box = document.createElement('div');
box.className = 'approval-box';
box.innerHTML = '<div class="approval-title"><span>🛡️</span> 작업 승인 대기 중 (Action Approval Required)</div>' +
'<div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">위의 변경 사항을 프로젝트에 반영할까요?</div>' +
'<div class="approval-btns">' +
' <button class="btn-approve" onclick="approve()">승인 (Approve)</button>' +
' <button class="btn-reject" onclick="reject()">롤백 (Rollback)</button>' +
'</div>';
chat.appendChild(box);
chat.scrollTop = chat.scrollHeight;
break;
}
});
@@ -1327,16 +1644,36 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
});
input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; });
document.getElementById('newChatBtn').onclick = () => vscode.postMessage({ type: 'newChat' });
const startNewChat = () => { Sound.play(660, 'sine', 0.1); vscode.postMessage({ type: 'newChat' }); };
document.getElementById('newChatBtn').onclick = startNewChat;
document.getElementById('inputNewChatBtn').onclick = startNewChat;
document.getElementById('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
document.getElementById('internetBtn').onclick = () => {
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled);
};
document.getElementById('brainBtn').onclick = () => vscode.postMessage({ type: 'syncBrain' });
let multiAgentEnabled = true;
document.getElementById('multiAgentBtn').onclick = () => {
multiAgentEnabled = !multiAgentEnabled;
vscode.postMessage({ type: 'toggleMultiAgent', value: multiAgentEnabled });
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
};
const syncBrain = () => { Sound.play(550, 'sine', 0.1); vscode.postMessage({ type: 'syncBrain' }); };
document.getElementById('brainBtn').onclick = syncBrain;
document.getElementById('inputSyncBtn').onclick = syncBrain;
document.getElementById('historyBtn').onclick = () => vscode.postMessage({ type: 'getSessions' });
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('visible');
modelSel.onchange = () => vscode.postMessage({ type: 'refreshModels' });
const updateInputPlaceholder = () => {
promptInput.placeholder = \`Ask \${modelSel.value}...\`;
};
modelSel.onchange = () => {
vscode.postMessage({ type: 'model', value: modelSel.value });
updateInputPlaceholder();
};
brainSel.onchange = () => {
if (brainSel.value === 'new') {
vscode.postMessage({ type: 'addBrain' });
@@ -1345,6 +1682,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
}
};
// Handle initial state and state updates from extension
window.addEventListener('message', e => {
const msg = e.data;
if (msg.type === 'configUpdate') {
multiAgentEnabled = msg.value.multiAgentEnabled;
document.getElementById('multiAgentBtn').classList.toggle('active', multiAgentEnabled);
}
});
agentSel.onchange = () => {
if (agentSel.value !== 'none') {
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
@@ -1381,6 +1728,25 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
vscode.postMessage({ type: 'getModels' });
vscode.postMessage({ type: 'getAgents' });
vscode.postMessage({ type: 'ready' });
// --- Proactive Behavioral Tracking ---
let hoverTimer = null;
const trackBehavior = (elementId, context) => {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
vscode.postMessage({ type: 'proactiveTrigger', context: context });
}, 5000); // 5 seconds threshold
});
el.addEventListener('mouseleave', () => {
if (hoverTimer) clearTimeout(hoverTimer);
});
};
trackBehavior('settingsBtn', 'settings_exploration');
trackBehavior('brainBtn', 'brain_sync_exploration');
trackBehavior('agentSel', 'agent_selection_exploration');
</script>
</body>
</html>`;
+7 -97
View File
@@ -3,103 +3,7 @@ import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
export const EXCLUDED_DIRS = new Set([
'node_modules', '.git', '.vscode', 'out', 'dist', 'build',
'.next', '.cache', '__pycache__', '.DS_Store', 'coverage',
'.turbo', '.nuxt', '.output', 'vendor', 'target'
]);
// Configuration constants moved to package.json and getConfig()
export interface BrainProfile {
id: string;
name: string;
localBrainPath: string;
secondBrainRepo?: string;
description?: string;
}
function normalizePath(p: string): string {
if (!p) return p;
if (p.startsWith('~/')) {
return path.join(os.homedir(), p.substring(2));
}
return p.trim();
}
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
if (!raw) return null;
const localBrainPath = normalizePath(raw.localBrainPath || '');
if (!localBrainPath) return null;
return {
id: (raw.id || `brain-${fallbackIndex + 1}`).trim(),
name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(),
localBrainPath,
secondBrainRepo: (raw.secondBrainRepo || '').trim(),
description: (raw.description || '').trim()
};
}
const STEVE_SKILL_PATH = '/Volumes/Data/project/Antigravity/Agent/.agent/skills/steve_jobs/SKILL.md';
function extractSteveRuntimeSection(skillContent: string): string {
const sectionStart = skillContent.indexOf('## 🎯 Steve Single-Contact Runtime Protocol');
if (sectionStart < 0) return '';
const sectionEnd = skillContent.indexOf('### 4. The "One More Thing" Mandate', sectionStart);
return skillContent.slice(sectionStart, sectionEnd > sectionStart ? sectionEnd : undefined).trim();
}
function loadSteveRuntimePrompt(): string {
try {
if (!fs.existsSync(STEVE_SKILL_PATH)) return '';
const skillContent = fs.readFileSync(STEVE_SKILL_PATH, 'utf-8');
return extractSteveRuntimeSection(skillContent);
} catch {
return '';
}
}
export function getConfig() {
const cfg = vscode.workspace.getConfiguration('g1nation');
const legacyBrainPath = cfg.get<string>('localBrainPath', '');
const legacyBrainRepo = cfg.get<string>('secondBrainRepo', '');
const configuredProfiles = cfg.get<Partial<BrainProfile>[]>('brainProfiles', []);
const profiles = configuredProfiles
.map((profile, index) => toBrainProfile(profile, index))
.filter((profile): profile is BrainProfile => !!profile);
// IMPORTANT: This virtual default-brain exists only in memory at runtime.
// It must NEVER be written back to the settings file (g1nation.brainProfiles).
// _addBrainProfile() reads cfg.get('brainProfiles') directly to avoid this contamination.
if (profiles.length === 0) {
const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain');
profiles.push({
id: 'default-brain',
name: 'Local Brain',
localBrainPath: fallbackPath,
secondBrainRepo: legacyBrainRepo.trim(),
description: legacyBrainPath
? 'Migrated from your existing localBrainPath setting'
: 'Auto-created local knowledge folder. Add a real brain via the ✎ button.'
});
}
const activeBrainId = cfg.get<string>('activeBrainId', profiles[0].id) || profiles[0].id;
const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0];
return {
ollamaUrl: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'),
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
maxTreeFiles: 200,
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
localBrainPath: activeBrain.localBrainPath,
secondBrainRepo: activeBrain.secondBrainRepo || '',
brainProfiles: profiles,
activeBrainId: activeBrain.id,
maxContextSize: cfg.get<number>('maxContextSize', 12000),
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50)
};
}
import { getConfig, BrainProfile, EXCLUDED_DIRS } from './config';
export type EngineKind = 'lmstudio' | 'ollama';
@@ -243,6 +147,12 @@ Core behavior:
- Use the active Local Brain only when it is relevant to the user's question. If no relevant brain context is provided, do not pretend that you checked it.
- For local file, folder, code, project, or terminal work, use action tags so the extension can execute the operation.
- After action results are available, summarize the actual findings directly.
- ALWAYS explain your thought process using the <rationale> tag BEFORE performing any actions. Use the following structure:
<rationale>
[PROBLEM] Description of the issue or need found in the context.
[GOAL] What you intend to achieve with your proposed changes.
[REASONING] Detailed logical basis for choosing specific actions or architecture.
</rationale>
Available action tags:
+68
View File
@@ -0,0 +1,68 @@
/// <reference types="jest" />
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { TransactionManager } from '../src/core/transaction';
describe('TransactionManager', () => {
let tm: TransactionManager;
let testDir: string;
beforeEach(() => {
tm = new TransactionManager();
testDir = path.join(os.tmpdir(), `g1-test-${Date.now()}`);
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
test('should record and rollback file creation', () => {
const testFile = path.join(testDir, 'test.txt');
tm.begin();
tm.record(testFile);
fs.writeFileSync(testFile, 'hello world');
expect(fs.existsSync(testFile)).toBe(true);
tm.rollback();
expect(fs.existsSync(testFile)).toBe(false);
});
test('should record and rollback file modification', () => {
const testFile = path.join(testDir, 'edit.txt');
fs.writeFileSync(testFile, 'original');
tm.begin();
tm.record(testFile);
fs.writeFileSync(testFile, 'modified');
expect(fs.readFileSync(testFile, 'utf8')).toBe('modified');
tm.rollback();
expect(fs.readFileSync(testFile, 'utf8')).toBe('original');
});
test('should delete backup files after commit', () => {
const testFile = path.join(testDir, 'commit.txt');
fs.writeFileSync(testFile, 'data');
tm.begin();
tm.record(testFile);
fs.writeFileSync(testFile, 'new data');
tm.commit();
expect(fs.readFileSync(testFile, 'utf8')).toBe('new data');
// Backups are in ~/.g1nation-backups (hardcoded in TM, but let's assume it cleans up internal state)
expect(tm.isActive()).toBe(false);
});
});
+60
View File
@@ -0,0 +1,60 @@
/// <reference types="jest" />
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { AsyncLockManager } from '../src/core/lock';
import { TransactionManager } from '../src/core/transaction';
describe('System Vulnerability Tests', () => {
let testDir: string;
beforeEach(() => {
testDir = path.join(os.tmpdir(), `g1-vulnerability-test-${Date.now()}`);
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
test('AsyncLockManager should prevent race conditions on the same file', async () => {
const lockManager = new AsyncLockManager();
const testFile = path.join(testDir, 'race_condition.txt');
fs.writeFileSync(testFile, '0');
// Simulate 10 concurrent increments
const tasks = Array.from({ length: 10 }).map(async (_, i) => {
const release = await lockManager.acquire(testFile);
try {
const current = parseInt(fs.readFileSync(testFile, 'utf-8'));
// Artificial delay to increase race condition probability
await new Promise(r => setTimeout(r, 10));
fs.writeFileSync(testFile, (current + 1).toString());
} finally {
release();
}
});
await Promise.all(tasks);
const finalValue = fs.readFileSync(testFile, 'utf-8');
expect(finalValue).toBe('10'); // If race condition occurs, it will be < 10
});
test('TransactionManager should support external verification hooks', () => {
const tm = new TransactionManager();
tm.begin();
tm.recordExternalAction('DB_COMMIT', true);
tm.recordExternalAction('API_PUSH', true);
expect(tm.isFullyVerified()).toBe(true);
tm.recordExternalAction('WIKI_SYNC', false);
expect(tm.isFullyVerified()).toBe(false);
});
});
+4 -2
View File
@@ -3,9 +3,11 @@
"module": "commonjs",
"target": "ES2022",
"outDir": "out",
"lib": ["ES2022"],
"lib": ["ES2022", "DOM"],
"sourceMap": true,
"strict": true
"strict": true,
"types": ["node", "jest"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", ".vscode-test"]
}