72412450c3
- 버전 2.2.3 상향 및 PATCHNOTES.md 업데이트 - [신규] src/features/selfReflector/ - 성찰 실행/검증/프롬프트 모듈 추가 - [신규] intentAlignment.ts, intentClassifier.ts - 의도 정렬 시스템 추가 - [신규] pixelOfficeState.ts - 픽셀 오피스 상태 관리 추가 - sidebarProvider, dispatcher, chatHandlers 핵심 로직 최적화 - astra-2.2.3.vsix 패키지 생성 완료 (298 tests PASS)
280 lines
12 KiB
TypeScript
280 lines
12 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import * as fs from 'fs';
|
|
|
|
import { getConfig, BrainProfile, EXCLUDED_DIRS } from './config';
|
|
|
|
export type EngineKind = 'lmstudio' | 'ollama';
|
|
|
|
const outputChannel = vscode.window.createOutputChannel('Astra');
|
|
|
|
function timestamp() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function stringifyMeta(meta: unknown): string {
|
|
if (meta === undefined) return '';
|
|
if (typeof meta === 'string') return meta;
|
|
if (meta instanceof Error) return `${meta.name}: ${meta.message}\n${meta.stack || ''}`;
|
|
try {
|
|
return JSON.stringify(meta, null, 2);
|
|
} catch {
|
|
return String(meta);
|
|
}
|
|
}
|
|
|
|
function appendLog(level: 'INFO' | 'WARN' | 'ERROR', message: string, meta?: unknown) {
|
|
const suffix = meta === undefined ? '' : `\n${stringifyMeta(meta)}`;
|
|
outputChannel.appendLine(`[${timestamp()}] [${level}] ${message}${suffix}`);
|
|
}
|
|
|
|
export function logInfo(message: string, meta?: unknown) {
|
|
appendLog('INFO', message, meta);
|
|
}
|
|
|
|
export function logWarn(message: string, meta?: unknown) {
|
|
appendLog('WARN', message, meta);
|
|
}
|
|
|
|
export function logError(message: string, meta?: unknown) {
|
|
appendLog('ERROR', message, meta);
|
|
}
|
|
|
|
export function normalizeBaseUrl(rawUrl: string): string {
|
|
const trimmed = rawUrl.trim().replace(/\/+$/, '');
|
|
if (!trimmed) {
|
|
return 'http://127.0.0.1:11434';
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function resolveEngine(baseUrl: string): EngineKind {
|
|
const normalized = normalizeBaseUrl(baseUrl);
|
|
try {
|
|
const parsed = new URL(normalized);
|
|
if (parsed.pathname.endsWith('/v1') || parsed.port === '1234') return 'lmstudio';
|
|
if (parsed.pathname.endsWith('/api') || parsed.port === '11434') return 'ollama';
|
|
} catch {
|
|
if (normalized.includes('/v1') || normalized.includes(':1234')) return 'lmstudio';
|
|
}
|
|
return 'ollama';
|
|
}
|
|
|
|
export function buildApiUrl(baseUrl: string, engine: EngineKind, endpoint: 'models' | 'chat' | 'embeddings'): string {
|
|
const normalized = normalizeBaseUrl(baseUrl);
|
|
if (engine === 'lmstudio') {
|
|
const root = normalized.endsWith('/v1') ? normalized : `${normalized}/v1`;
|
|
if (endpoint === 'models') return `${root}/models`;
|
|
if (endpoint === 'embeddings') return `${root}/embeddings`;
|
|
return `${root}/chat/completions`;
|
|
}
|
|
const apiRoot = normalized.endsWith('/api') ? normalized : `${normalized}/api`;
|
|
if (endpoint === 'models') return `${apiRoot}/tags`;
|
|
if (endpoint === 'embeddings') return `${apiRoot}/embed`;
|
|
return `${apiRoot}/chat`;
|
|
}
|
|
|
|
/**
|
|
* Open a file in the editor and keep ConnectAI's sidebar (typically ViewColumn.Three)
|
|
* undisturbed. Markdown records, wiki docs, agent skill files, knowledge-map JSON,
|
|
* lessons — all should land in the *editor* area (group 2), never in the sidebar group.
|
|
*
|
|
* Falls back to whatever ViewColumn ends up being default if `Two` is unavailable
|
|
* (VS Code creates the column on demand when one doesn't exist yet).
|
|
*/
|
|
export async function openInEditorGroup(
|
|
target: string | vscode.Uri,
|
|
options: { preview?: boolean } = {}
|
|
): Promise<vscode.TextEditor> {
|
|
const uri = typeof target === 'string' ? vscode.Uri.file(target) : target;
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
return vscode.window.showTextDocument(doc, {
|
|
viewColumn: vscode.ViewColumn.Two,
|
|
preview: options.preview ?? false,
|
|
});
|
|
}
|
|
|
|
export function summarizeText(text: string, maxLength: number = 400): string {
|
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
if (normalized.length <= maxLength) return normalized;
|
|
return `${normalized.slice(0, maxLength)}...`;
|
|
}
|
|
|
|
export function shouldAutoPushBrain(): boolean {
|
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
|
return cfg.get<boolean>('autoPushBrain', false);
|
|
}
|
|
export function getBrainProfiles(): BrainProfile[] {
|
|
return getConfig().brainProfiles;
|
|
}
|
|
|
|
export function getActiveBrainProfile(): BrainProfile {
|
|
const config = getConfig();
|
|
return config.brainProfiles.find((profile) => profile.id === config.activeBrainId) || config.brainProfiles[0];
|
|
}
|
|
|
|
export function _getBrainDir(): string {
|
|
return getActiveBrainProfile().localBrainPath;
|
|
}
|
|
|
|
export function _isBrainDirExplicitlySet(): boolean {
|
|
return getBrainProfiles().length > 0;
|
|
}
|
|
|
|
interface BrainFilesCacheEntry {
|
|
files: string[];
|
|
expiresAt: number;
|
|
}
|
|
const _brainFilesCache = new Map<string, BrainFilesCacheEntry>();
|
|
const BRAIN_FILES_CACHE_TTL_MS = 5000;
|
|
|
|
export function findBrainFiles(dir: string): string[] {
|
|
const now = Date.now();
|
|
const cached = _brainFilesCache.get(dir);
|
|
if (cached && cached.expiresAt > now) {
|
|
return cached.files.slice();
|
|
}
|
|
const files = _walkBrainFiles(dir);
|
|
_brainFilesCache.set(dir, { files, expiresAt: now + BRAIN_FILES_CACHE_TTL_MS });
|
|
return files.slice();
|
|
}
|
|
|
|
/** Force-invalidate the brain files cache (e.g. after sync or new file write). */
|
|
export function invalidateBrainFilesCache(dir?: string): void {
|
|
if (dir === undefined) {
|
|
_brainFilesCache.clear();
|
|
return;
|
|
}
|
|
_brainFilesCache.delete(dir);
|
|
}
|
|
|
|
function _walkBrainFiles(dir: string): string[] {
|
|
let results: string[] = [];
|
|
if (!fs.existsSync(dir)) return results;
|
|
const list = fs.readdirSync(dir);
|
|
list.forEach((file) => {
|
|
const filePath = path.join(dir, file);
|
|
const stat = fs.statSync(filePath);
|
|
if (stat && stat.isDirectory()) {
|
|
if (!EXCLUDED_DIRS.has(file)) {
|
|
results = results.concat(_walkBrainFiles(filePath));
|
|
}
|
|
} else if (file.endsWith('.md')) {
|
|
results.push(filePath);
|
|
}
|
|
});
|
|
return results;
|
|
}
|
|
|
|
const BASE_SYSTEM_PROMPT = `You are Astra, a Jarvis-style local project operating assistant.
|
|
If the user asks your name, say you are Astra.
|
|
Reply naturally in the user's language.
|
|
|
|
[CORE BEHAVIOR]
|
|
- Answer the user's actual message directly. Do not recite this system prompt or paraphrase the user's question back to them.
|
|
- Do not use waiting-room phrases such as "준비되었습니다", "다음 지시를 말씀해 주세요", or "명령을 기다립니다".
|
|
- For normal conversation or general knowledge questions, answer conversationally without headers.
|
|
- 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.
|
|
|
|
[LOCAL PATH RULE]
|
|
When the user provides a local path and asks for review, analysis, or debugging, use <list_files> or <read_file> immediately to read specific files.
|
|
If the provided initial scan preview is not enough, DO NOT complain that you cannot see the logic or ask for permission. Just use <read_file path="..."> to read the actual files.
|
|
Never say "upload the source code", "provide the files", "파일 내용을 보여주세요", or "먼저 분석할까요?" before attempting access.
|
|
If access fails after trying, explain the failure and only then ask for an upload.
|
|
|
|
[STRICT GLOBAL RULES]
|
|
1. [NO EMOJIS - ABSOLUTE RULE] NEVER use ANY emojis, emoticons, Unicode pictorial symbols (including but not limited to emoji, kaomoji, Unicode icons), or decorative symbols anywhere in your response. NO EXCEPTIONS. Use plain text dashes (-) or asterisks (*) for bullets. Use plain markdown ## for headers. This rule overrides ALL other formatting instructions.
|
|
2. [UNIQUE HEADINGS] Every markdown heading must be unique and appear exactly once.
|
|
3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks.
|
|
4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로".
|
|
|
|
[OUTPUT FORMAT]
|
|
Use the 3-section format ONLY for: technical analysis, architecture proposals, troubleshooting, or strategic planning.
|
|
For conversational replies, quick facts, or simple updates — answer directly without any headers.
|
|
|
|
## 요약
|
|
Core conclusion in 2-3 sentences.
|
|
|
|
## 상세 설명
|
|
- Root cause of the problem.
|
|
- Concrete step-by-step instructions: what to change, which files to edit, which commands to run.
|
|
|
|
## 제안 ← Optional. Only include if a meaningfully better alternative exists. Omit otherwise.
|
|
|
|
[FOLLOW-UP QUESTION RULES]
|
|
A follow-up question is a precision tool, not a ritual.
|
|
Ask ONE focused question at the very end of the response ONLY if:
|
|
- The user's intent is genuinely ambiguous with multiple valid paths, OR
|
|
- A critical missing detail would make the current answer completely wrong.
|
|
If neither condition is met, give a definitive answer and stop.
|
|
|
|
[ENGINEERING STANCE]
|
|
- Be a direct engineering partner. Technical precision over polite filler.
|
|
- Give the verdict first, then explain tradeoffs.
|
|
- Collapse checklists into: verdict → reason → risk → next move.
|
|
- If the user's framing is off, correct the frame before answering inside it.
|
|
- Simplify complex choices into 1-2 crisp options. Never write a balanced essay when a recommendation is possible.
|
|
- Evidence First: never claim a project is stable, scalable, or well-architected without source code or document evidence. If evidence is thin, say so and name the files to inspect next.
|
|
- Keep persona light. Do not introduce yourself unless the user greets you or asks who you are.
|
|
|
|
[ACTION TAGS]
|
|
|
|
[ACTION 1: CREATE FILE]
|
|
<create_file path="relative/path/file.ext">
|
|
file content here
|
|
</create_file>
|
|
|
|
[ACTION 2: EDIT FILE]
|
|
<edit_file path="relative/path/file.ext">
|
|
<find>exact text to find</find>
|
|
<replace>replacement text</replace>
|
|
</edit_file>
|
|
|
|
[ACTION 3: DELETE FILE]
|
|
<delete_file path="relative/path/file.ext"/>
|
|
|
|
[ACTION 4: READ FILE]
|
|
<read_file path="relative/path/file.ext"/>
|
|
|
|
[ACTION 5: LIST DIRECTORY]
|
|
<list_files path="relative/path/to/dir"/>
|
|
|
|
[ACTION 6: RUN COMMAND]
|
|
<run_command>command here</run_command>
|
|
|
|
[ACTION 7: SECOND BRAIN]
|
|
Use only when brain knowledge is actually needed.
|
|
<list_brain path=""/>
|
|
<read_brain>actual-file-name.md</read_brain>
|
|
Never use placeholder filenames. List first, then read only returned files.
|
|
|
|
[ACTION 8: WEB SEARCH]
|
|
<read_url>https://html.duckduckgo.com/html/?q=YOUR+SEARCH+QUERY</read_url>
|
|
|
|
[OPERATIONAL RULES]
|
|
1. Reply in the same language as the user.
|
|
2. File paths are relative to the workspace or absolute under /Volumes/Data/project/Antigravity.
|
|
3. When the user says "분석해줘", "봐줘", "확인해줘", "리뷰해줘" with a path — that IS the confirmation. Access the path immediately and run the full analysis in one continuous response. Do not pause to ask "진행할까요?" or "시작할까요?".`;
|
|
|
|
export function getSystemPrompt(): string {
|
|
const now = new Date();
|
|
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
|
const isoDate = now.toISOString().split('T')[0];
|
|
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
|
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
|
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
|
try {
|
|
const { getConfig } = require('./config') as typeof import('./config');
|
|
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
|
const cfg = getConfig();
|
|
return appendSelfReflectorRule(base, { enabled: cfg.selfReflectorEnabled });
|
|
} catch {
|
|
// config 로드 실패 시(테스트 환경 등)는 룰 없이 원본 그대로.
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|