eeb527c242
- /youtube: 4-렌즈 분석 → 대본(스크립트) 역기획서 포맷으로 개편, 보고서 앞에 영상 전체 스크립트(Full Script) 출력, 명령어 보조 컨텍스트 지원 - /wikify: 신규 슬래시 명령 — 웹사이트 본문(/api/web-extract)을 P-Reinforce v3.0 위키 문서로 합성. 여러 링크 순차 배치 처리, 명세 문서 완전성 규칙, 위키링크 자동 교정 - Self-Reflector Phase A 기본 비활성화 — [Self-Reflector Check] 내부 검증 로그가 사용자 답변에 노출되지 않도록 - 슬래시 합성·일반 채팅 시스템 프롬프트에 출력 위생 규칙 추가 — 한·영 토큰 깨짐 정제, 내부 검증 로그 출력 금지 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
358 lines
16 KiB
TypeScript
358 lines
16 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: 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).
|
|
|
|
<create_calendar_event title="회의 제목" start="2026-05-21T14:00" duration="60" location="회의실 A">
|
|
설명 (선택) — 회의록 요약 / 안건 등
|
|
</create_calendar_event>
|
|
|
|
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 권한도 함께 발급) 필요.
|
|
|
|
<read_sheet spreadsheet_id="1abc...xyz" range="Sheet1!A1:D20"/>
|
|
|
|
- spreadsheet_id: Google Sheets URL 의 /d/<여기>/edit 부분
|
|
- range: A1 notation. 시트명 포함 가능. 예: 'Sheet1!A1:E50', '데이터!B:B'
|
|
|
|
[ACTION 10: WRITE SHEET]
|
|
Range 의 좌상단부터 값을 *덮어쓴다*. 본문은 TSV (탭 구분, 줄바꿈 = 행).
|
|
탭이 한 칸도 없으면 ' | ' 파이프 구분으로 자동 fallback.
|
|
|
|
<write_sheet spreadsheet_id="1abc..." range="Sheet1!A1">
|
|
이름\t나이\t직책
|
|
민지\t29\t디자이너
|
|
</write_sheet>
|
|
|
|
[ACTION 11: APPEND SHEET]
|
|
Range 안에서 *가장 마지막 데이터 행 아래* 에 새 행으로 append. 로그·일지에 유용.
|
|
|
|
<append_sheet spreadsheet_id="1abc..." range="Sheet1!A:C">
|
|
2026-05-21\t새 항목\t완료
|
|
</append_sheet>
|
|
|
|
⚠ Sheets 사용 규칙:
|
|
- spreadsheet_id 는 사용자가 알려준 것만. 추측·생성 금지.
|
|
- 사용자가 "내 시트" 같이 추상적으로 지칭하면 *URL 을 받아온 뒤* 사용.
|
|
- 쓰기 전에는 반드시 "이 시트에 이런 데이터를 쓰겠다" 한 줄 미리 알리기 (실수 방지).
|
|
|
|
[ACTION 12: ADD TASK]
|
|
회의록·요청·계획 분석 중 *명확한 할일* 이 발견되면 작업 추적기에 등록.
|
|
추적기는 모든 agent 가 다음 turn 부터 자동으로 보게 됨 → 진척 가시화 + 누락 방지.
|
|
|
|
<add_task title="광고주 자료 정리" owner="@me" due="2026-05-24T18:00" notes="자료 수령 후 시작"/>
|
|
|
|
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 로 주면 됨 (다른 값은 보존).
|
|
|
|
<update_task id="t_001" status="in_progress" notes="자료 수령 완료, 정리 진행 중"/>
|
|
|
|
[ACTION 14: COMPLETE TASK]
|
|
task 가 끝났을 때. active 에서 빼고 done 으로 이동, completedAt 자동 기록.
|
|
|
|
<complete_task id="t_001"/>
|
|
|
|
⚠ 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;
|