Files
connectai/src/utils.ts
T
koriweb eeb527c242 feat(datacollect): /youtube 개편·/wikify 신규·출력 위생 (v2.2.48)
- /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>
2026-05-20 18:34:07 +09:00

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;