feat: v2.12.0 - UI/UX Refinement (Model Sync & Premium Tooltips)
This commit is contained in:
@@ -21,6 +21,25 @@
|
|||||||
- Removed premature empty stream chunks that were causing UI flickering.
|
- Removed premature empty stream chunks that were causing UI flickering.
|
||||||
- Verified build stability with `v2.2.46` VSIX package.
|
- 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*
|
*Date: 2026-04-25*
|
||||||
*Version: 2.2.46*
|
*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) 적용
|
||||||
@@ -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. 체크리스트
|
||||||
|
- [ ] 새로운 버튼이 기존의 아이콘 문법을 따르는가?
|
||||||
|
- [ ] 생성/편집 액션이 정의된 위치에 배치되었는가?
|
||||||
|
- [ ] 상태 변화에 대해 일관된 색상과 방식으로 피드백을 주는가?
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/tests/**/*.test.ts'],
|
||||||
|
moduleFileExtensions: ['ts', 'js'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.ts$': 'ts-jest',
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+3812
-2
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "g1nation",
|
"name": "g1nation",
|
||||||
"displayName": "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.",
|
"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",
|
"publisher": "connectailab",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -95,6 +95,11 @@
|
|||||||
"configuration": {
|
"configuration": {
|
||||||
"title": "G1nation",
|
"title": "G1nation",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"g1nation.multiAgentEnabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
|
||||||
|
},
|
||||||
"g1nation.ollamaUrl": {
|
"g1nation.ollamaUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "http://127.0.0.1:11434",
|
"default": "http://127.0.0.1:11434",
|
||||||
@@ -169,6 +174,11 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 50,
|
"default": 50,
|
||||||
"description": "Maximum autonomous steps the agent can take per request. 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"
|
"pretest": "npm run compile"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
"@types/node": "18.x",
|
"@types/node": "18.x",
|
||||||
"@types/vscode": "^1.80.0",
|
"@types/vscode": "^1.80.0",
|
||||||
"@vercel/ncc": "^0.38.4",
|
"@vercel/ncc": "^0.38.4",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.4.9",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+428
-179
@@ -3,10 +3,8 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
// axios removed
|
// axios removed
|
||||||
import {
|
import {
|
||||||
getConfig,
|
|
||||||
_getBrainDir,
|
_getBrainDir,
|
||||||
findBrainFiles,
|
findBrainFiles,
|
||||||
EXCLUDED_DIRS,
|
|
||||||
getSystemPrompt,
|
getSystemPrompt,
|
||||||
shouldAutoPushBrain,
|
shouldAutoPushBrain,
|
||||||
getSecondBrainRepo,
|
getSecondBrainRepo,
|
||||||
@@ -17,16 +15,53 @@ import {
|
|||||||
resolveEngine,
|
resolveEngine,
|
||||||
summarizeText
|
summarizeText
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { getConfig, EXCLUDED_DIRS } from './config';
|
||||||
import { validatePath, sanitizeCommand } from './security';
|
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 {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string | any[];
|
content: string;
|
||||||
internal?: boolean;
|
internal?: boolean;
|
||||||
|
rationale?: {
|
||||||
|
problem: string;
|
||||||
|
goal: string;
|
||||||
|
reasoning: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryChangeListener = (history: ChatMessage[]) => void | Promise<void>;
|
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 {
|
export class AgentExecutor {
|
||||||
private chatHistory: ChatMessage[] = [];
|
private chatHistory: ChatMessage[] = [];
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -34,10 +69,44 @@ export class AgentExecutor {
|
|||||||
private historyChangeListener: HistoryChangeListener | undefined;
|
private historyChangeListener: HistoryChangeListener | undefined;
|
||||||
private runSerial = 0;
|
private runSerial = 0;
|
||||||
private activeRunId = 0;
|
private activeRunId = 0;
|
||||||
|
private transactionManager: TransactionManager;
|
||||||
|
private sessionManager: SessionManager;
|
||||||
|
private statusBarManager: StatusBarManager;
|
||||||
|
private currentTaskId: string = 'default_session';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private context: vscode.ExtensionContext
|
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) {
|
public setWebview(webview: vscode.Webview) {
|
||||||
this.webview = webview;
|
this.webview = webview;
|
||||||
@@ -75,6 +144,20 @@ export class AgentExecutor {
|
|||||||
this.emitHistoryChanged();
|
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(
|
public async handlePrompt(
|
||||||
prompt: string | null,
|
prompt: string | null,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
@@ -98,19 +181,35 @@ export class AgentExecutor {
|
|||||||
temperature = 0.7,
|
temperature = 0.7,
|
||||||
systemPrompt = getSystemPrompt()
|
systemPrompt = getSystemPrompt()
|
||||||
} = options;
|
} = options;
|
||||||
|
const { ollamaUrl, defaultModel: configDefaultModel, timeout, multiAgentEnabled } = getConfig();
|
||||||
const runId = options.runId ?? (loopDepth === 0 ? ++this.runSerial : this.activeRunId);
|
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;
|
const hasVisionContent = Array.isArray(visionContent) ? visionContent.length > 0 : !!visionContent;
|
||||||
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
let requestTimeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
if (!this.webview) return;
|
if (!this.webview) return;
|
||||||
|
|
||||||
try {
|
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 (loopDepth === 0) {
|
||||||
if (this.abortController) {
|
if (this.abortController) {
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
this.abortController = null;
|
this.abortController = null;
|
||||||
}
|
}
|
||||||
this.activeRunId = runId;
|
this.activeRunId = runId;
|
||||||
|
this.currentTaskId = `task_${Date.now()}`;
|
||||||
await this.context.workspaceState.update('lastActionStr', undefined);
|
await this.context.workspaceState.update('lastActionStr', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +269,8 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. API Request Setup
|
// 3. API Request Setup
|
||||||
const { ollamaUrl, defaultModel, timeout } = getConfig();
|
const { ollamaUrl, defaultModel: configDefaultModel, timeout } = getConfig();
|
||||||
|
const actualModel = modelName || configDefaultModel;
|
||||||
const reqMessages = [...this.chatHistory];
|
const reqMessages = [...this.chatHistory];
|
||||||
|
|
||||||
// Handle Vision Content Injection
|
// Handle Vision Content Injection
|
||||||
@@ -184,7 +284,7 @@ export class AgentExecutor {
|
|||||||
: [];
|
: [];
|
||||||
reqMessages[lastUserIdx] = {
|
reqMessages[lastUserIdx] = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [...textParts, ...(visionContent || [])]
|
content: JSON.stringify([...textParts, ...(visionContent || [])])
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,12 +308,12 @@ export class AgentExecutor {
|
|||||||
// 4. Call AI Engine
|
// 4. Call AI Engine
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
requestTimeoutHandle = setTimeout(() => {
|
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();
|
this.abortController?.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
const request = await this.createStreamingRequest({
|
const request = await this.createStreamingRequest({
|
||||||
baseUrl: ollamaUrl,
|
baseUrl: ollamaUrl,
|
||||||
modelName: modelName || defaultModel,
|
modelName: actualModel,
|
||||||
reqMessages: messagesForRequest,
|
reqMessages: messagesForRequest,
|
||||||
temperature
|
temperature
|
||||||
});
|
});
|
||||||
@@ -283,12 +383,15 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Execute Actions
|
// 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.chatHistory.push(assistantMessage);
|
||||||
|
|
||||||
|
this.statusBarManager.updateStatus(AgentStatus.Executing);
|
||||||
const report = await this.executeActions(aiResponseText, rootPath);
|
const report = await this.executeActions(aiResponseText, rootPath);
|
||||||
if (!aiResponseText.trim() && report.length === 0) {
|
if (!aiResponseText.trim() && report.length === 0) {
|
||||||
this.chatHistory.pop();
|
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.' });
|
this.webview.postMessage({ type: 'error', value: 'AI model returned an empty response.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,9 +435,11 @@ export class AgentExecutor {
|
|||||||
|
|
||||||
assistantMessage.internal = false;
|
assistantMessage.internal = false;
|
||||||
this.emitHistoryChanged();
|
this.emitHistoryChanged();
|
||||||
|
this.statusBarManager.updateStatus(AgentStatus.Success);
|
||||||
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
|
this.webview.postMessage({ type: 'streamChunk', value: aiResponseText });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
this.statusBarManager.updateStatus(AgentStatus.Error, error.message);
|
||||||
logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) });
|
logError('Agent prompt failed.', { error: error?.message || String(error), promptPreview: summarizeText(prompt || '', 200) });
|
||||||
if (!this.isStaleRun(runId)) {
|
if (!this.isStaleRun(runId)) {
|
||||||
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
|
this.webview.postMessage({ type: "error", value: `[Agent Error]: ${error.message}` });
|
||||||
@@ -383,16 +488,15 @@ export class AgentExecutor {
|
|||||||
if (explicitPath) return explicitPath;
|
if (explicitPath) return explicitPath;
|
||||||
|
|
||||||
const namedProject = prompt.match(/([A-Za-z0-9._-]+)\s*(?:프로젝트|project)/i)?.[1];
|
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 = [
|
const searchRoots = [
|
||||||
|
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '',
|
||||||
'/Volumes/Data/project/Antigravity',
|
'/Volumes/Data/project/Antigravity',
|
||||||
'/Volumes/Data/project',
|
|
||||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
for (const root of searchRoots) {
|
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;
|
if (resolved) return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,10 +528,108 @@ export class AgentExecutor {
|
|||||||
return null;
|
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 {
|
private isProjectAnalysisRequest(normalized: string): boolean {
|
||||||
// Intentionally conservative: omit generic words like '조사', '설명', '파악'
|
// Only trigger local analysis if the intent is strictly about a project-level overview.
|
||||||
// that appear in ordinary chat and would incorrectly bypass LM Studio
|
// Avoid generic terms like 'analysis' or 'review' that are common in general coding chat.
|
||||||
return /(분석|리뷰|설계|구조|어떤 프로그램|무슨 프로그램|제품 설명)/.test(normalized);
|
const hasProjectKeyword = /(프로젝트|project|레포|repository)/.test(normalized);
|
||||||
|
const hasAnalysisIntent = /(전체 요약|제품 설명|어떤 프로그램|구조 파악|훑어보기)/.test(normalized);
|
||||||
|
|
||||||
|
return hasProjectKeyword && hasAnalysisIntent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildProjectAnalysisReply(projectPath: string): string {
|
private buildProjectAnalysisReply(projectPath: string): string {
|
||||||
@@ -691,6 +893,13 @@ export class AgentExecutor {
|
|||||||
private emitHistoryChanged() {
|
private emitHistoryChanged() {
|
||||||
if (!this.historyChangeListener) return;
|
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) => {
|
Promise.resolve(this.historyChangeListener(this.getHistory())).catch((error: any) => {
|
||||||
logError('History change listener failed.', { error: error?.message || String(error) });
|
logError('History change listener failed.', { error: error?.message || String(error) });
|
||||||
});
|
});
|
||||||
@@ -818,179 +1027,219 @@ export class AgentExecutor {
|
|||||||
let brainModified = false;
|
let brainModified = false;
|
||||||
let firstCreatedFile: string | undefined;
|
let firstCreatedFile: string | undefined;
|
||||||
|
|
||||||
// Action 1: Create File
|
try {
|
||||||
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
this.transactionManager.begin();
|
||||||
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}`); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action 2: Edit File
|
// Action 1: Create File
|
||||||
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
const createRegex = /<create_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/create_file>/gi;
|
||||||
while ((match = editRegex.exec(aiMessage)) !== null) {
|
let match;
|
||||||
const relPath = match[1].trim();
|
while ((match = createRegex.exec(aiMessage)) !== null) {
|
||||||
const editContent = match[2].trim();
|
const relPath = match[1].trim();
|
||||||
try {
|
const content = match[2].trim();
|
||||||
const absPath = validatePath(rootPath, relPath);
|
try {
|
||||||
if (fs.existsSync(absPath)) {
|
const absPath = validatePath(rootPath, relPath);
|
||||||
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
await this.transactionManager.record(absPath);
|
||||||
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
|
||||||
if (searchMatch) {
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
const searchStr = searchMatch[1];
|
fs.writeFileSync(absPath, content, 'utf-8');
|
||||||
const replaceStr = searchMatch[2];
|
|
||||||
if (currentContent.includes(searchStr)) {
|
report.push(`✅ Created: ${relPath}`);
|
||||||
currentContent = currentContent.replace(searchStr, replaceStr);
|
if (!firstCreatedFile) firstCreatedFile = absPath;
|
||||||
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;
|
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||||
} else {
|
} catch (err: any) {
|
||||||
report.push(`❌ File not found: ${relPath}`);
|
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
|
// Action 2: Edit File
|
||||||
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
const editRegex = /<edit_file\s+path=['"]?([^'"]+)['"]?>([\s\S]*?)<\/edit_file>/gi;
|
||||||
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
while ((match = editRegex.exec(aiMessage)) !== null) {
|
||||||
const relPath = match[1].trim();
|
const relPath = match[1].trim();
|
||||||
try {
|
const editContent = match[2].trim();
|
||||||
const absPath = validatePath(rootPath, relPath);
|
try {
|
||||||
if (fs.existsSync(absPath)) {
|
const absPath = validatePath(rootPath, relPath);
|
||||||
fs.unlinkSync(absPath);
|
if (fs.existsSync(absPath)) {
|
||||||
report.push(`🗑 Deleted: ${relPath}`);
|
await this.transactionManager.record(absPath);
|
||||||
} else {
|
|
||||||
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
let currentContent = fs.readFileSync(absPath, 'utf-8');
|
||||||
}
|
const searchMatch = editContent.match(/<search>([\s\S]*?)<\/search>\s*<replace>([\s\S]*?)<\/replace>/i);
|
||||||
} catch (err: any) { report.push(`❌ Error Deleting ${relPath}: ${err.message}`); }
|
|
||||||
}
|
if (searchMatch) {
|
||||||
|
const searchStr = searchMatch[1];
|
||||||
// Action 4: Read File
|
const replaceStr = searchMatch[2];
|
||||||
const readRegex = /<read_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/read_file>)?/gi;
|
if (currentContent.includes(searchStr)) {
|
||||||
while ((match = readRegex.exec(aiMessage)) !== null) {
|
currentContent = currentContent.replace(searchStr, replaceStr);
|
||||||
const relPath = match[1].trim();
|
fs.writeFileSync(absPath, currentContent, 'utf-8');
|
||||||
try {
|
report.push(`📝 Updated: ${relPath}`);
|
||||||
const absPath = validatePath(rootPath, relPath);
|
} else {
|
||||||
if (fs.existsSync(absPath)) {
|
report.push(`⚠️ Search string not found in ${relPath}`);
|
||||||
const content = fs.readFileSync(absPath, 'utf-8');
|
}
|
||||||
const preview = content.length > 8000 ? content.slice(0, 8000) + "\n... (truncated)" : content;
|
} else {
|
||||||
report.push(`📖 Read: ${relPath}`);
|
fs.writeFileSync(absPath, editContent, 'utf-8');
|
||||||
this.chatHistory.push({ role: 'system', content: `[Result of read_file ${relPath}]\n\`\`\`\n${preview}\n\`\`\``, internal: true });
|
report.push(`📝 Updated (Full): ${relPath}`);
|
||||||
} else {
|
}
|
||||||
report.push(`❌ Read failed: ${relPath} not found`);
|
if (absPath.startsWith(_getBrainDir())) brainModified = true;
|
||||||
}
|
} else {
|
||||||
} catch (err: any) { report.push(`❌ Error Reading ${relPath}: ${err.message}`); }
|
report.push(`❌ File not found: ${relPath}`);
|
||||||
}
|
|
||||||
|
|
||||||
// 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)";
|
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
report.push(`📂 Listed: ${relPath}`);
|
throw new FileSystemError(`Failed to edit file ${relPath}: ${err.message}`, relPath, err);
|
||||||
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)
|
// Action 3: Delete File
|
||||||
const listBrainRegex = /<list_brain\s*path=['"]?([^'"]*)['"]?\s*\/?>(?:<\/list_brain>)?/gi;
|
const deleteRegex = /<delete_file\s+path=['"]?([^'"]+)['"]?\s*\/?>(?:<\/delete_file>)?/gi;
|
||||||
while ((match = listBrainRegex.exec(aiMessage)) !== null) {
|
while ((match = deleteRegex.exec(aiMessage)) !== null) {
|
||||||
const relPath = match[1].trim() || '.';
|
const relPath = match[1].trim();
|
||||||
try {
|
try {
|
||||||
const brainDir = _getBrainDir();
|
const absPath = validatePath(rootPath, relPath);
|
||||||
const absPath = path.join(brainDir, relPath);
|
if (fs.existsSync(absPath)) {
|
||||||
if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
await this.transactionManager.record(absPath);
|
||||||
const entries = fs.readdirSync(absPath, { withFileTypes: true });
|
fs.unlinkSync(absPath);
|
||||||
let listing = entries
|
report.push(`🗑 Deleted: ${relPath}`);
|
||||||
.filter(e => !e.name.startsWith('.') && !EXCLUDED_DIRS.has(e.name))
|
} else {
|
||||||
.map(e => e.isDirectory() ? `${e.name}/` : e.name)
|
report.push(`⚠️ Delete failed: ${relPath} not found`);
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
if (listing.length > 5000) {
|
|
||||||
listing = listing.slice(0, 5000) + "\n... (truncated for context)";
|
|
||||||
}
|
}
|
||||||
|
} 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}`);
|
if (targetFile && fs.existsSync(targetFile)) {
|
||||||
this.chatHistory.push({ role: 'system', content: `[Result of list_brain ${relPath}]\n${listing}`, internal: true });
|
const content = fs.readFileSync(targetFile, 'utf-8');
|
||||||
} else {
|
report.push(`🧠 Brain Read: ${fileName}`);
|
||||||
report.push(`❌ Brain List failed: ${relPath} not found`);
|
this.chatHistory.push({ role: 'system', content: `[Result of read_brain ${fileName}]\n\`\`\`\n${content}\n\`\`\``, internal: true });
|
||||||
}
|
} else {
|
||||||
} catch (err: any) { report.push(`❌ Error Listing Brain: ${err.message}`); }
|
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;
|
// Action 8: Read URL
|
||||||
while ((match = brainRegex.exec(aiMessage)) !== null) {
|
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
|
||||||
const fileName = match[1].trim();
|
while ((match = urlRegex.exec(aiMessage)) !== null) {
|
||||||
try {
|
const url = match[1].trim();
|
||||||
const brainDir = _getBrainDir();
|
try {
|
||||||
const files = findBrainFiles(brainDir);
|
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||||
// Look for direct match or path match
|
const text = await res.text();
|
||||||
const targetFile = files.find((f: string) => path.basename(f) === fileName || f.endsWith(fileName));
|
const content = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const preview = content.length > 5000 ? content.slice(0, 5000) + "\n... (truncated)" : content;
|
||||||
if (targetFile && fs.existsSync(targetFile)) {
|
report.push(`🌐 Read URL: ${url}`);
|
||||||
const content = fs.readFileSync(targetFile, 'utf-8');
|
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
|
||||||
report.push(`🧠 Brain Read: ${fileName}`);
|
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
||||||
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 (Simple implementation)
|
if (firstCreatedFile) {
|
||||||
const urlRegex = /<read_url>([\s\S]*?)<\/read_url>/gi;
|
vscode.window.showTextDocument(vscode.Uri.file(firstCreatedFile), { preview: false });
|
||||||
while ((match = urlRegex.exec(aiMessage)) !== null) {
|
}
|
||||||
const url = match[1].trim();
|
|
||||||
try {
|
// Brain Sync Logic
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
if (brainModified && shouldAutoPushBrain() && getSecondBrainRepo()) {
|
||||||
const text = await res.text();
|
this.syncBrain();
|
||||||
// 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;
|
const config = getConfig();
|
||||||
report.push(`🌐 Read URL: ${url}`);
|
if (config.dryRun) {
|
||||||
this.chatHistory.push({ role: 'system', content: `[Result of read_url ${url}]\n${preview}`, internal: true });
|
report.push(`\n⚠️ **Dry Run Mode Active**: 위 변경 사항을 확인하고 [승인] 또는 [롤백]을 선택해주세요.`);
|
||||||
} catch (err: any) { report.push(`❌ URL Read failed: ${err.message}`); }
|
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) {
|
if (firstCreatedFile) {
|
||||||
|
|||||||
@@ -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
@@ -3,16 +3,17 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
// axios removed
|
// axios removed
|
||||||
import {
|
import {
|
||||||
getConfig,
|
|
||||||
_getBrainDir,
|
_getBrainDir,
|
||||||
_isBrainDirExplicitlySet,
|
_isBrainDirExplicitlySet,
|
||||||
findBrainFiles,
|
findBrainFiles,
|
||||||
buildApiUrl,
|
buildApiUrl,
|
||||||
logError,
|
logError,
|
||||||
logInfo,
|
logInfo,
|
||||||
|
logWarn,
|
||||||
resolveEngine,
|
resolveEngine,
|
||||||
summarizeText
|
summarizeText
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { getConfig } from './config';
|
||||||
|
|
||||||
export interface BridgeInterface {
|
export interface BridgeInterface {
|
||||||
injectSystemMessage(msg: string): void;
|
injectSystemMessage(msg: string): void;
|
||||||
|
|||||||
+120
-64
@@ -1,97 +1,153 @@
|
|||||||
/**
|
|
||||||
* ============================================================
|
|
||||||
* Centralized Configuration (중앙 집중식 설정 관리)
|
|
||||||
*
|
|
||||||
* 모든 환경 설정(모델 이름, API 엔드포인트, 타임아웃, 보안 정책 등)
|
|
||||||
* 을 한 곳에서 관리합니다. Single Source of Truth 원칙 적용.
|
|
||||||
* ============================================================
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
// ─── VS Code 설정에서 읽어오는 값 ───
|
// ─── 브레인 프로필 인터페이스 ───
|
||||||
export function getConfig(): IAgentConfig {
|
export interface BrainProfile {
|
||||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
id: string;
|
||||||
return {
|
name: string;
|
||||||
ollamaBase: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434'),
|
localBrainPath: string;
|
||||||
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b'),
|
secondBrainRepo?: string;
|
||||||
maxTreeFiles: cfg.get<number>('maxTreeFiles', 200),
|
description?: string;
|
||||||
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
|
|
||||||
localBrainPath: cfg.get<string>('localBrainPath', '')
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 에이전트 설정 인터페이스 ───
|
// ─── 에이전트 설정 인터페이스 (통합 버전) ───
|
||||||
export interface IAgentConfig {
|
export interface IAgentConfig {
|
||||||
ollamaBase: string;
|
ollamaUrl: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
maxTreeFiles: number;
|
maxTreeFiles: number;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
localBrainPath: string;
|
localBrainPath: string;
|
||||||
|
secondBrainRepo: string;
|
||||||
|
brainProfiles: BrainProfile[];
|
||||||
|
activeBrainId: string;
|
||||||
|
maxContextSize: number;
|
||||||
|
maxAutoSteps: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
multiAgentEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 두뇌 폴더 경로 유틸리티 ───
|
// ─── 경로 정규화 유틸리티 ───
|
||||||
export function getBrainDir(): string {
|
function normalizePath(p: string): string {
|
||||||
const { localBrainPath } = getConfig();
|
if (!p) return p;
|
||||||
if (localBrainPath && localBrainPath.trim() !== '') {
|
if (p.startsWith('~/')) {
|
||||||
if (localBrainPath.startsWith('~/')) {
|
return path.join(os.homedir(), p.substring(2));
|
||||||
return path.join(os.homedir(), localBrainPath.substring(2));
|
|
||||||
}
|
|
||||||
return localBrainPath.trim();
|
|
||||||
}
|
}
|
||||||
return path.join(os.homedir(), '.g1nation-brain');
|
return p.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBrainDirExplicitlySet(): boolean {
|
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
|
||||||
const { localBrainPath } = getConfig();
|
if (!raw) return null;
|
||||||
return !!(localBrainPath && localBrainPath.trim() !== '');
|
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) ───
|
// ─── 보안 정책 (Security Policy) ───
|
||||||
export const SECURITY_POLICY = {
|
export const SECURITY_POLICY = {
|
||||||
// 허용된 터미널 명령어 프리픽스 (Whitelist)
|
|
||||||
allowedCommandPrefixes: [
|
allowedCommandPrefixes: [
|
||||||
'npm', 'yarn', 'pnpm', 'npx',
|
'npm', 'yarn', 'pnpm', 'npx', 'node', 'ts-node', 'git', 'python', 'python3', 'pip', 'pip3',
|
||||||
'node', 'ts-node',
|
'docker', 'docker-compose', 'ls', 'dir', 'cat', 'type', 'echo', 'print', 'cargo', 'go', 'rustc',
|
||||||
'git',
|
'java', 'javac', 'mvn', 'gradle', 'flutter', 'dart', 'pub', 'webpack', 'vite', 'esbuild', 'parcel',
|
||||||
'python', 'python3', 'pip', 'pip3',
|
'jest', 'mocha', 'vitest', 'cypress', 'tsc', 'vue-tsc',
|
||||||
'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: [
|
forbiddenCommands: [
|
||||||
'rm -rf', 'rm-rf', 'del /f', 'format',
|
'rm -rf', 'rm-rf', 'del /f', 'format', 'mkfs', 'dd if=', ':(){ :|:& };:',
|
||||||
'mkfs', 'dd if=', ':(){ :|:& };:',
|
'wget http', 'curl http', 'sudo', 'chmod 777', 'chown root',
|
||||||
'wget http', 'curl http', 'sudo',
|
|
||||||
'chmod 777', 'chown root',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 민감한 파일 패턴 (파일 생성/수정 시 경고)
|
|
||||||
sensitiveFilePatterns: [
|
sensitiveFilePatterns: [
|
||||||
'.env', '.env.*',
|
'.env', '.env.*', 'id_rsa', 'id_ed25519', '.gitconfig', '.npmrc', '.pypirc',
|
||||||
'id_rsa', 'id_ed25519',
|
|
||||||
'.gitconfig', '.npmrc', '.pypirc',
|
|
||||||
'credentials.json', 'service-account.json',
|
'credentials.json', 'service-account.json',
|
||||||
],
|
],
|
||||||
|
maxFileSize: 10 * 1024 * 1024,
|
||||||
// 파일 생성 시 최대 크기 (bytes)
|
|
||||||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
||||||
|
|
||||||
// 맥락 파일 최대 개수
|
|
||||||
maxContextFiles: 200,
|
maxContextFiles: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── 시스템 프롬프트 상수 ───
|
|
||||||
export const MAX_CONTEXT_SIZE = 12_000;
|
export const MAX_CONTEXT_SIZE = 12_000;
|
||||||
|
|
||||||
export const EXCLUDED_DIRS = new Set([
|
export const EXCLUDED_DIRS = new Set([
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: '로그를 확인하거나 확장을 재시작해보세요.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -3,7 +3,6 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
// axios removed in favor of native fetch
|
// axios removed in favor of native fetch
|
||||||
import {
|
import {
|
||||||
getConfig,
|
|
||||||
_getBrainDir,
|
_getBrainDir,
|
||||||
_isBrainDirExplicitlySet,
|
_isBrainDirExplicitlySet,
|
||||||
findBrainFiles,
|
findBrainFiles,
|
||||||
@@ -12,15 +11,28 @@ import {
|
|||||||
logError,
|
logError,
|
||||||
logInfo
|
logInfo
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { getConfig, validateConfig } from './config';
|
||||||
import { AgentExecutor } from './agent';
|
import { AgentExecutor } from './agent';
|
||||||
import { BridgeServer } from './bridge';
|
import { BridgeServer } from './bridge';
|
||||||
import { SidebarChatProvider } from './sidebarProvider';
|
import { SidebarChatProvider } from './sidebarProvider';
|
||||||
|
import { HealthCheckMonitor } from './core/health';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* G1nation Extension Entry Point
|
* G1nation Extension Entry Point
|
||||||
*/
|
*/
|
||||||
export async function activate(context: vscode.ExtensionContext) {
|
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
|
// 1. Ensure Brain Directory
|
||||||
await _ensureBrainDir(context);
|
await _ensureBrainDir(context);
|
||||||
|
|||||||
+381
-15
@@ -2,7 +2,6 @@ import * as vscode from 'vscode';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
getConfig,
|
|
||||||
_getBrainDir,
|
_getBrainDir,
|
||||||
findBrainFiles,
|
findBrainFiles,
|
||||||
buildApiUrl,
|
buildApiUrl,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
resolveEngine,
|
resolveEngine,
|
||||||
summarizeText
|
summarizeText
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
import { getConfig } from './config';
|
||||||
import { AgentExecutor, ChatMessage } from './agent';
|
import { AgentExecutor, ChatMessage } from './agent';
|
||||||
import { BridgeInterface } from './bridge';
|
import { BridgeInterface } from './bridge';
|
||||||
|
|
||||||
@@ -88,8 +88,12 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
await this._sendBrainProfiles();
|
await this._sendBrainProfiles();
|
||||||
await this._sendSessionList();
|
await this._sendSessionList();
|
||||||
await this._sendModels();
|
await this._sendModels();
|
||||||
|
await this._sendConfig();
|
||||||
await this._restoreActiveSessionIntoView();
|
await this._restoreActiveSessionIntoView();
|
||||||
break;
|
break;
|
||||||
|
case 'toggleMultiAgent':
|
||||||
|
await vscode.workspace.getConfiguration('g1nation').update('multiAgentEnabled', data.value, vscode.ConfigurationTarget.Global);
|
||||||
|
break;
|
||||||
case 'getModels':
|
case 'getModels':
|
||||||
await this._sendModels();
|
await this._sendModels();
|
||||||
break;
|
break;
|
||||||
@@ -128,6 +132,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
await this.syncBrain();
|
await this.syncBrain();
|
||||||
await this._sendBrainStatus();
|
await this._sendBrainStatus();
|
||||||
break;
|
break;
|
||||||
|
case 'addMessage':
|
||||||
|
this._view?.webview.postMessage({ type: 'addMessage', role: data.role, value: data.value, rationale: data.rationale });
|
||||||
|
break;
|
||||||
case 'addBrain':
|
case 'addBrain':
|
||||||
await this._addBrainProfile();
|
await this._addBrainProfile();
|
||||||
break;
|
break;
|
||||||
@@ -143,6 +150,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
case 'refreshModels':
|
case 'refreshModels':
|
||||||
await this._sendModels();
|
await this._sendModels();
|
||||||
break;
|
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':
|
case 'exportResponse':
|
||||||
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
|
const workspacePath = vscode.workspace.workspaceFolders?.[0].uri.fsPath || '';
|
||||||
const defaultPath = path.join(workspacePath, 'g1_response.md');
|
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)}`);
|
vscode.window.showInformationMessage(`✅ Exported to ${path.basename(uri.fsPath)}`);
|
||||||
}
|
}
|
||||||
break;
|
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));
|
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() {
|
private async _sendBrainStatus() {
|
||||||
if (!this._view) return;
|
if (!this._view) return;
|
||||||
const activeBrain = getActiveBrainProfile();
|
const activeBrain = getActiveBrainProfile();
|
||||||
@@ -631,6 +662,29 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
|
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() {
|
private async _createAgent() {
|
||||||
const name = await vscode.window.showInputBox({
|
const name = await vscode.window.showInputBox({
|
||||||
prompt: 'Name of the new Agent (e.g., frontend_expert)',
|
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); }
|
.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 { 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); }
|
.icon-btn:hover { background: var(--border); color: var(--text-bright); }
|
||||||
.av-ai { background: var(--accent); color: #fff; }
|
.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 {
|
.msg-body {
|
||||||
padding-left: 30px;
|
padding-left: 30px;
|
||||||
@@ -997,11 +1067,13 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
|
|
||||||
/* --- Overlays & Others --- */
|
/* --- Overlays & Others --- */
|
||||||
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
.thinking-bar { height: 2px; background: transparent; position: relative; overflow: hidden; margin-top: -1px; }
|
||||||
.thinking-bar.active::after {
|
.thinking-bar.active {
|
||||||
content: ''; position: absolute; top: 0; left: -40%; width: 40%; height: 100%;
|
display: block;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent), transparent); animation: think 1.5s infinite;
|
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 {
|
.history-overlay {
|
||||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8);
|
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;
|
margin-bottom: 10px; cursor: pointer; transition: 0.2s;
|
||||||
}
|
}
|
||||||
.history-item:hover { border-color: var(--accent); background: var(--accent-glow); }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1043,9 +1249,10 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
<select id="agentSel" title="Select Agentic Skill"></select>
|
<select id="agentSel" title="Select Agentic Skill"></select>
|
||||||
<button class="icon-btn" id="editAgentBtn" title="Edit Agent Skill">📝</button>
|
<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="addAgentBtn" title="Create Agent">+</button>
|
||||||
<button class="icon-btn" id="internetBtn" title="Internet Access">🌐</button>
|
<button class="icon-btn" id="multiAgentBtn" data-tooltip="Multi-Agent Mode">🤖</button>
|
||||||
<button class="icon-btn" id="brainBtn" title="Sync Knowledge">🧠</button>
|
<button class="icon-btn" id="internetBtn" data-tooltip="Internet Access">🌐</button>
|
||||||
<button class="icon-btn" id="historyBtn" title="History">📜</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1059,6 +1266,15 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
|
|
||||||
<div class="thinking-bar" id="thinkingBar"></div>
|
<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="chat" id="chat">
|
||||||
<div class="welcome">
|
<div class="welcome">
|
||||||
<div class="welcome-logo">✦</div>
|
<div class="welcome-logo">✦</div>
|
||||||
@@ -1088,6 +1304,11 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
<button id="sendBtn" class="send-btn">Send</button>
|
<button id="sendBtn" class="send-btn">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<input type="file" id="fileInput" multiple hidden accept="image/*,.txt,.md,.pdf,.csv,.json,.js,.ts,.py,.java,.rs,.go">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1098,6 +1319,52 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
const sendBtn = document.getElementById('sendBtn');
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
const thinkingBar = document.getElementById('thinkingBar');
|
const thinkingBar = document.getElementById('thinkingBar');
|
||||||
const statusLabel = document.getElementById('statusLabel');
|
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 modelSel = document.getElementById('modelSel');
|
||||||
const brainSel = document.getElementById('brainSel');
|
const brainSel = document.getElementById('brainSel');
|
||||||
const historyOverlay = document.getElementById('historyOverlay');
|
const historyOverlay = document.getElementById('historyOverlay');
|
||||||
@@ -1133,16 +1400,45 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
document.body.removeChild(textarea);
|
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) {
|
function exportToMD(text) {
|
||||||
vscode.postMessage({ type: 'exportResponse', text: text });
|
vscode.postMessage({ type: 'exportResponse', text: text });
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMsg(text, role) {
|
function addMsg(text, role, rationale) {
|
||||||
const isUser = role === 'user';
|
const isUser = role === 'user';
|
||||||
const msgEl = document.createElement('div');
|
const msgEl = document.createElement('div');
|
||||||
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
|
msgEl.className = 'msg ' + (isUser ? 'msg-user' : 'msg-ai');
|
||||||
msgEl._raw = text;
|
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');
|
const head = document.createElement('div');
|
||||||
head.className = 'msg-head';
|
head.className = 'msg-head';
|
||||||
head.innerHTML = isUser ? '<div class="av av-user">U</div> You' : '<div class="av av-ai">✦</div> G1nation';
|
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 => {
|
window.addEventListener('message', e => {
|
||||||
const msg = e.data;
|
const msg = e.data;
|
||||||
switch(msg.type) {
|
switch(msg.type) {
|
||||||
|
case 'addMessage':
|
||||||
|
addMsg(msg.value, msg.role, msg.rationale);
|
||||||
|
break;
|
||||||
case 'streamStart':
|
case 'streamStart':
|
||||||
thinkingBar.classList.remove('active');
|
thinkingBar.classList.remove('active');
|
||||||
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
|
if (document.querySelector('.welcome')) document.querySelector('.welcome').remove();
|
||||||
@@ -1196,6 +1495,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
case 'streamEnd':
|
case 'streamEnd':
|
||||||
if (streamBody) streamBody.classList.remove('stream-active');
|
if (streamBody) streamBody.classList.remove('stream-active');
|
||||||
streamBody = null; sendBtn.disabled = false;
|
streamBody = null; sendBtn.disabled = false;
|
||||||
|
resetStepper();
|
||||||
|
Sound.success();
|
||||||
break;
|
break;
|
||||||
case 'restoreHistory':
|
case 'restoreHistory':
|
||||||
case 'sessionLoaded':
|
case 'sessionLoaded':
|
||||||
@@ -1204,7 +1505,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
|
|
||||||
if (history && history.length > 0) {
|
if (history && history.length > 0) {
|
||||||
chat.innerHTML = '';
|
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) {
|
if (historyData.negativePrompt !== undefined) {
|
||||||
negativePrompt.value = historyData.negativePrompt;
|
negativePrompt.value = historyData.negativePrompt;
|
||||||
@@ -1224,6 +1525,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
if (m === msg.value.selected) o.selected = true;
|
if (m === msg.value.selected) o.selected = true;
|
||||||
modelSel.appendChild(o);
|
modelSel.appendChild(o);
|
||||||
});
|
});
|
||||||
|
if (typeof updateInputPlaceholder === 'function') updateInputPlaceholder();
|
||||||
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
|
statusLabel.innerText = \`Model: \${msg.value.selected}\`;
|
||||||
break;
|
break;
|
||||||
case 'brainProfiles':
|
case 'brainProfiles':
|
||||||
@@ -1251,6 +1553,9 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
break;
|
break;
|
||||||
case 'autoContinue':
|
case 'autoContinue':
|
||||||
statusLabel.innerText = msg.value; thinkingBar.classList.add('active');
|
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);
|
setTimeout(() => { thinkingBar.classList.remove('active'); }, 3000);
|
||||||
break;
|
break;
|
||||||
case 'agentsList':
|
case 'agentsList':
|
||||||
@@ -1272,6 +1577,18 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
|
thinkingBar.classList.remove('active'); sendBtn.disabled = false;
|
||||||
addMsg(msg.value, 'error');
|
addMsg(msg.value, 'error');
|
||||||
break;
|
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'; });
|
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('settingsBtn').onclick = () => vscode.postMessage({ type: 'openSettings' });
|
||||||
document.getElementById('internetBtn').onclick = () => {
|
document.getElementById('internetBtn').onclick = () => {
|
||||||
internetEnabled = !internetEnabled; document.getElementById('internetBtn').classList.toggle('active', internetEnabled);
|
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').onclick = () => vscode.postMessage({ type: 'getSessions' });
|
||||||
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
document.getElementById('historyBtn').addEventListener('click', () => historyOverlay.classList.add('visible'));
|
||||||
document.getElementById('closeHistoryBtn').onclick = () => historyOverlay.classList.remove('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 = () => {
|
brainSel.onchange = () => {
|
||||||
if (brainSel.value === 'new') {
|
if (brainSel.value === 'new') {
|
||||||
vscode.postMessage({ type: 'addBrain' });
|
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 = () => {
|
agentSel.onchange = () => {
|
||||||
if (agentSel.value !== 'none') {
|
if (agentSel.value !== 'none') {
|
||||||
vscode.postMessage({ type: 'getAgentContent', path: agentSel.value });
|
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: 'getModels' });
|
||||||
vscode.postMessage({ type: 'getAgents' });
|
vscode.postMessage({ type: 'getAgents' });
|
||||||
vscode.postMessage({ type: 'ready' });
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
+7
-97
@@ -3,103 +3,7 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
||||||
export const EXCLUDED_DIRS = new Set([
|
import { getConfig, BrainProfile, EXCLUDED_DIRS } from './config';
|
||||||
'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)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EngineKind = 'lmstudio' | 'ollama';
|
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.
|
- 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.
|
- 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.
|
- 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:
|
Available action tags:
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -3,9 +3,11 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"outDir": "out",
|
"outDir": "out",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"types": ["node", "jest"]
|
||||||
},
|
},
|
||||||
|
"include": ["src/**/*", "tests/**/*"],
|
||||||
"exclude": ["node_modules", ".vscode-test"]
|
"exclude": ["node_modules", ".vscode-test"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user