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 { 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('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(); 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 or 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 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
, "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] file content here [ACTION 2: EDIT FILE] exact text to find replacement text [ACTION 3: DELETE FILE] [ACTION 4: READ FILE] [ACTION 5: LIST DIRECTORY] [ACTION 6: RUN COMMAND] command here [ACTION 7: SECOND BRAIN] Use only when brain knowledge is actually needed. actual-file-name.md Never use placeholder filenames. List first, then read only returned files. [ACTION 8: CREATE CALENDAR EVENT] Use only when the user shares meeting notes / agenda / due dates and a real event should land on their Google Calendar. Requires the user to have run "Astra: Google Calendar OAuth 연결 (쓰기)" — if not connected the tag will fail cleanly (reported in the action log). 설명 (선택) — 회의록 요약 / 안건 등 Attributes: title (required) — 한 줄 제목 start (required) — 'YYYY-MM-DDTHH:MM' 로컬, 또는 timezone 포함 ISO end | duration — end 없으면 duration(분, default 60) 으로 자동 계산 location (optional) all_day="true" — DTSTART 만 'YYYY-MM-DD' 형식으로 Emit *one tag per event*. Never invent times the user didn't mention — if unclear, ask first. Do not emit tags for vague phrases like "다음주에" without a concrete time. [ACTION 9: READ SHEET] Google Sheets 의 셀 범위를 읽어 chat 컨텍스트에 마크다운 테이블로 주입한다. 같은 OAuth 권한 (Calendar 연결 시 Sheets 권한도 함께 발급) 필요. - spreadsheet_id: Google Sheets URL 의 /d/<여기>/edit 부분 - range: A1 notation. 시트명 포함 가능. 예: 'Sheet1!A1:E50', '데이터!B:B' [ACTION 10: WRITE SHEET] Range 의 좌상단부터 값을 *덮어쓴다*. 본문은 TSV (탭 구분, 줄바꿈 = 행). 탭이 한 칸도 없으면 ' | ' 파이프 구분으로 자동 fallback. 이름\t나이\t직책 민지\t29\t디자이너 [ACTION 11: APPEND SHEET] Range 안에서 *가장 마지막 데이터 행 아래* 에 새 행으로 append. 로그·일지에 유용. 2026-05-21\t새 항목\t완료 ⚠ Sheets 사용 규칙: - spreadsheet_id 는 사용자가 알려준 것만. 추측·생성 금지. - 사용자가 "내 시트" 같이 추상적으로 지칭하면 *URL 을 받아온 뒤* 사용. - 쓰기 전에는 반드시 "이 시트에 이런 데이터를 쓰겠다" 한 줄 미리 알리기 (실수 방지). [ACTION 12: ADD TASK] 회의록·요청·계획 분석 중 *명확한 할일* 이 발견되면 작업 추적기에 등록. 추적기는 모든 agent 가 다음 turn 부터 자동으로 보게 됨 → 진척 가시화 + 누락 방지. Attributes (title 만 필수): title — 한 줄 요약 (required) owner — @me / @planner / @qa 등 자유 형식 due — 'YYYY-MM-DDTHH:MM' (없으면 마감 없는 task) notes — 한 줄 부가 설명 status — open(default) / in_progress / blocked [ACTION 13: UPDATE TASK] 진척·blocker·due 변경. id 는 추적기에 표시된 t_001 같은 식별자. 바꿀 필드만 attribute 로 주면 됨 (다른 값은 보존). [ACTION 14: COMPLETE TASK] task 가 끝났을 때. active 에서 빼고 done 으로 이동, completedAt 자동 기록. ⚠ Task 사용 규칙: - 사용자가 *명시적으로* 할일이라고 언급한 것만 add — 추측·확장 금지. - 회의록에 할일이 여러 개면 각각 *별도 add_task* (한 태그에 욱여넣지 말 것). - 진척 보고가 들어오면 즉시 update / complete. "추적해뒀어요" 라고 말만 하지 말 것. - due 시각이 명확한 task 는 add_task + create_calendar_event 함께 emit (둘 다). [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").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.`; // 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;