Files
connectai/src/utils.ts
T
koriweb 72412450c3 feat: v2.2.3 - Stability, Self-Reflector & Intent Alignment
- 버전 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)
2026-05-15 14:16:14 +09:00

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;