Files
connectai/src/config.ts
T
g1nation 0a97324f1b feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules)
  · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등)
  · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks)
  · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등)

R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리)

Stocks feature 신규: /stocks slash command (v2.2.152~158)
  · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신
  · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR)
  · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴
  · LLM Top 5 매력도 분석 + Telegram 자동 보고서
  · KST 09:00/15:00 watcher 자동 모니터링

대화 연속성 (v2.2.150~157):
  · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor
  · thin follow-up 분류 → boilerplate 헤더 suppression
  · slash 명령 결과 chatHistory mirror (capture wrapper)
  · echo/parrot 금지 system prompt rule

기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 09:59:32 +09:00

419 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
// ─── 브레인 프로필 인터페이스 ───
export interface BrainProfile {
id: string;
name: string;
localBrainPath: string;
secondBrainRepo?: string;
description?: string;
}
// ─── 에이전트 설정 인터페이스 (통합 버전) ───
export interface IAgentConfig {
ollamaUrl: string;
defaultModel: string;
maxTreeFiles: number;
timeout: number;
localBrainPath: string;
secondBrainRepo: string;
brainProfiles: BrainProfile[];
activeBrainId: string;
maxContextSize: number;
maxAutoSteps: number;
/** 채팅 응답 생성 temperature. 낮을수록 한국어 오타·깨진 토큰이 줄어든다. */
chatTemperature: number;
dryRun: boolean;
multiAgentEnabled: boolean;
memoryEnabled: boolean;
memoryShortTermMessages: number;
memoryMediumTermSessions: number;
memoryLongTermFiles: number;
// ─── 컨텍스트 한계 관리 ───
contextLength: number;
maxOutputTokens: number;
contextSafetyMargin: number;
contextOverflowPolicy: 'stopAtLimit' | 'truncateMiddle' | 'rollingWindow';
autoCompactHistory: boolean;
/** 작은 모델(≤4B) 감지 시 예산 계산에 쓸 유효 context window 상한. 0 = 비활성화. */
smallModelContextCap: number;
// ─── 응답 복구 (Thought Quarantine / Auto-Continuation) ───
/** 답변이 출력 토큰 한계에 걸리면 사용자 개입 없이 내부적으로 이어서 생성. */
autoContinueOnOutputLimit: boolean;
/** 자동 이어쓰기 최대 횟수 (무한 반복 방지). 0 = 비활성화. */
maxAutoContinuations: number;
/** 모델이 내부 사고만 출력하고 답변이 없으면 "최종 답변만" 지시로 1회 재생성. */
finalOnlyRetryOnThoughtLeak: boolean;
// ─── Hybrid Semantic Search ───
/**
* Embedding model name as registered in LM Studio / Ollama. Empty disables
* semantic search and the retriever falls back to TF-IDF only. The user
* must load this model in the engine before enabling it here.
*/
embeddingModel: string;
/**
* Blend between TF-IDF (sparse) and embedding cosine (dense) scoring.
* 0 = TF-IDF only (status quo), 1 = embedding only.
* Default 0.5 = equal weight, a reasonable starting point.
*/
embeddingBlendAlpha: number;
/**
* Global Knowledge Mix weight (0100). Controls how much the assistant leans on
* Second Brain evidence vs. model general knowledge when answering.
* 0 → Second Brain disabled; model knowledge only.
* 50 → Balanced (default).
* 100 → Second Brain is primary evidence; model knowledge only fills gaps.
* Per-agent overrides live in AgentKnowledgeEntry.secondBrainWeight and win.
*/
knowledgeMixSecondBrainWeight: number;
/**
* [Self-Reflection] Researcher와 Writer 사이에 메타인지 단계(Reflector)를 삽입할지 여부.
* true(기본): Reflector가 plan/research를 비판적으로 검토한 critique을 Writer에 주입.
* false: 기존 3단계(Planner→Researcher→Writer) 그대로 — 1 LLM 호출 절약 (저성능 모델/저지연 우선 시).
*/
/**
* Model id used by the 1인 기업 mode intent classifier (route message to
* pipeline vs casual chat). Empty → falls back to `defaultModel`. Recommended
* a fast small model (gemma e2b 등) so classification adds <1 s per send.
*/
companyIntentClassifierModel: string;
/**
* Bypass the intent classifier and always run the full pipeline. Legacy
* behaviour. Off by default because chat / question / thanks shouldn't
* dispatch all agents.
*/
companyDisableIntentClassifier: boolean;
/**
* 분류기가 추천한 파이프라인으로 *이번 turn만* 자동 전환할지. 켜면
* 사용자가 명시적으로 활성화해 둔 파이프라인보다 분류기 추천이 우선.
* 끄면 분류기 추천은 채팅 라벨에만 표시되고 dispatch는 사용자 활성
* 파이프라인 그대로. 처음 써 보는 사용자는 끈 채 추천만 보고 점차
* 신뢰 생기면 켜는 흐름을 권장 — 기본 false.
*/
companyAutoSelectPipeline: boolean;
/**
* Intent Alignment 모드. new_task 발생 시 사용자 의도를 C-G-C-F-Q로
* 정리하는 단계를 어떻게 다룰지.
* - 'off' : alignment 비활성. 분류기가 new_task 라고 하면 곧장 pipeline.
* - 'smart' : 기본값. confidence high면 자동 진행, medium/low면 사용자 확인.
* - 'strict' : confidence 무관 항상 사용자에게 contract 확인 카드 띄움.
*/
companyIntentAlignmentMode: 'off' | 'smart' | 'strict';
/** alignment 라운드 최대 횟수 (질문→답변 사이클). 1~5. */
companyIntentAlignmentMaxRounds: number;
/**
* Pixel Office 시각화 패널을 사이드바에 표시할지 여부. UI layer 전용 —
* 끈다고 Agent 행동이 바뀌지 않는다. Off면 webview는 패널을 숨기고
* 백엔드도 broadcast 자체를 skip해서 자원 절약.
*/
/**
* Self-Reflector Phase A — 모든 LLM 응답 끝에 [Self-Reflector Check]
* 자기검증 블록을 자동으로 붙이게 한다. 추가 LLM 콜 없음. 본질적으로
* 응답 품질 안전망이라 끌 이유는 적지만, 잡담 위주 환경에서 노이즈로
* 느껴진다면 꺼둘 수 있다.
*/
selfReflectorEnabled: boolean;
/**
* Self-Reflector Phase B — 회사 모드 specialist 응답 직후 *분리된 콘텍스트*
* 에서 LLM 한 번 더 호출해 외부 시각으로 검증. 실패 시 1회 retry. 비용
* 추가되므로 기본 OFF.
*/
selfReflectorExternalEnabled: boolean;
/**
* Self-Reflector Phase C — 코드 산출물에 한해 syntax/lint를 실제로 돌려
* 실행 기반 검증. Python: py_compile, JS: node --check, TS: tsc --noEmit.
* 실패 시 에러를 응답에 첨부. 기본 OFF — 사용자 환경에 toolchain이
* 깔려 있어야 의미가 있다.
*/
selfReflectorExecutionEnabled: boolean;
companyPixelOfficeEnabled: boolean;
/**
* Pixel Office의 캐릭터 말풍선 연출을 켤지. enabled가 true이고 이 값도
* true일 때만 말풍선이 생성된다. 시끄럽게 느껴지면 사용자가 끌 수 있게.
*/
companyPixelOfficeBubbles: boolean;
/**
* Multi-Agent 발동 모드:
* - 'auto' (기본): 작은 모델(≤4B) 감지 OR prompt가 컨텍스트의 큰 비중을 차지할 때만 자동 발동.
* - 'always': 인사·짧은 잡담을 제외한 모든 요청에 5단계 파이프라인 사용.
* - 'off': 기존 single-agent 동작 (수동 토글 / 키워드 매칭만 사용).
*/
workflowMultiAgentMode: 'auto' | 'always' | 'off';
/**
* 'auto' 모드에서 prompt + brain context 토큰이 contextLength 의 이 비율(0~1)을 넘으면 강제 5단계.
* 기본 0.30 — 작은 모델이 30% 이상을 input으로 먹기 시작하면 한 번에 끝내려는 시도가 위험.
*/
workflowAutoCtxFractionThreshold: number;
/**
* 절대 토큰 임계값 — 입력 prompt 가 이 값 *미만* 이면 Multi-Agent 파이프라인 발동
* 안 함 (키워드·길이 트리거 무시). 모델이 단일 호출로 처리.
*
* 의도: 사용자가 "요약/리뷰" 같은 키워드만 써도 chunked 가 강제로 발동해
* LLM 여러 번 호출되며 답변이 느려지는 문제 해결. 입력이 모델 윈도우 대비
* 충분히 작으면 한 번에 답하는 게 합리적.
*
* 기본 50000 — 대부분의 사용 환경에 적합. 매우 작은 컨텍스트 모델로 큰 입력을
* 자주 다룬다면 OOM 방지 차원에서 사용자가 직접 낮출 수 있음 (Astra Settings 패널).
*/
chunkedSwitchTokens: number;
/**
* Chunked 파이프라인 진입 시 outline 이 만들 수 있는 *최대 섹션 수*.
* 실제 LLM 호출 횟수 = 1(outline) + N(section) + 1(polish) = 2 + N.
* 따라서 이 값이 3이면 최대 5회, 4이면 최대 6회.
*
* 작을수록 답변 속도 빠름, 클수록 답변이 더 세분화. 기본 3 — 사용자
* 피드백("6회 이상은 과하다") 반영. 1~10 범위 clamp.
*/
chunkedMaxSections: number;
/**
* ChunkedWriter 의 polish persona 를 사용자가 override 할 텍스트. 비어 있으면
* 기본 polish persona (DEFAULT_POLISH_PERSONA) 사용. 입력이 있으면 그 텍스트가
* 그대로 system prompt 로 들어가 polish 단계의 톤·구조 룰을 정의.
*
* 의도: 사용자가 답변 톤을 (예: 격식체·반말·법률 문서·마케팅 카피) 도메인에
* 맞게 직접 조정 가능. 코드 변경 없이 Settings 패널 textarea 만으로.
*
* 빈 문자열 / 공백만이면 default 사용 — 잘못 입력해도 답변이 깨지진 않음.
*/
polishPersonaOverride: string;
// ─── Stream 표시 ───
/**
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
* - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단.
* - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음.
*/
liveStreamTokens: boolean;
/**
* 최종 답변 포맷.
* - 'plain' (기본): 모델이 무심코 내보낸 `##`, `**`, `__`, `> `, `* ` 등의 마크다운 마커를 후처리로 모두 제거.
* 섹션 라벨 텍스트(예: "핵심 요약")는 유지되지만 헤더 마커는 사라져 깔끔한 plain text 로 표시.
* - 'markdown': legacy 동작. 모델 출력을 그대로 렌더러에 넘김.
*/
outputFormat: 'plain' | 'markdown';
/**
* 자동 기록 (project chronicle auto-record). true 면 매 prompt 후 의미 있는 turn 을
* Wiki/Chronicle 폴더에 자동으로 저장. false 면 자동 저장 OFF (수동 기록은 계속 가능).
* 사이드바 도구 드롭다운의 토글 항목으로 즉시 변경 가능.
*/
chronicleAutoRecord: boolean;
// ─── LM Studio sampling (applied to both SDK and REST paths) ───
/** LM Studio nucleus sampling cutoff (0~1). Lower tightens; 1 disables. */
lmStudioTopP: number;
/** LM Studio top-K cutoff (0 disables). */
lmStudioTopK: number;
/** LM Studio min-P floor (0~1, 0 disables). */
lmStudioMinP: number;
/** LM Studio repeat penalty (1 disables, 1.051.2 typical). */
lmStudioRepeatPenalty: number;
/** Render tok/s + TTFT from prediction stats into context-budget badge. */
lmStudioShowStatsInBudget: boolean;
/** LM Studio model key of a small draft model for speculative decoding ('' = disabled). */
lmStudioDraftModel: string;
/** Load-time options. Read once per load(); changing these after load needs a reload. */
lmStudioLoad: {
flashAttention: boolean;
/** "max" | "off" | number 0-1 */
gpuOffloadRatio: 'max' | 'off' | number;
offloadKVCacheToGpu: boolean;
keepModelInMemory: boolean;
useFp16ForKVCache: boolean;
/** 0 = engine default */
evalBatchSize: number;
};
}
// ─── 경로 정규화 유틸리티 ───
function normalizePath(p: string): string {
if (!p) return p;
if (p.startsWith('~/')) {
return path.join(os.homedir(), p.substring(2));
}
return p.trim();
}
function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: number): BrainProfile | null {
if (!raw) return null;
const localBrainPath = normalizePath(raw.localBrainPath || '');
if (!localBrainPath) return null;
return {
id: (raw.id || `brain-${fallbackIndex + 1}`).trim(),
name: (raw.name || path.basename(localBrainPath) || `Brain ${fallbackIndex + 1}`).trim(),
localBrainPath,
secondBrainRepo: (raw.secondBrainRepo || '').trim(),
description: (raw.description || '').trim()
};
}
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
export function getConfig(): IAgentConfig {
const cfg = vscode.workspace.getConfiguration('g1nation');
// 브레인 프로필 로직 (utils.ts에서 이관)
const legacyBrainPath = cfg.get<string>('localBrainPath', '');
const legacyBrainRepo = cfg.get<string>('secondBrainRepo', '');
const configuredProfiles = cfg.get<Partial<BrainProfile>[]>('brainProfiles', []);
const profiles = configuredProfiles
.map((profile, index) => toBrainProfile(profile, index))
.filter((profile): profile is BrainProfile => !!profile);
if (profiles.length === 0) {
const fallbackPath = normalizePath(legacyBrainPath) || path.join(os.homedir(), '.g1nation-brain');
profiles.push({
id: 'default-brain',
name: 'Local Brain',
localBrainPath: fallbackPath,
secondBrainRepo: legacyBrainRepo.trim(),
description: legacyBrainPath
? 'Migrated from your existing localBrainPath setting'
: 'Auto-created local knowledge folder.'
});
}
const activeBrainId = cfg.get<string>('activeBrainId', profiles[0].id) || profiles[0].id;
const activeBrain = profiles.find((profile) => profile.id === activeBrainId) || profiles[0];
return {
ollamaUrl: cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434',
defaultModel: cfg.get<string>('defaultModel', 'gemma4:e2b') || 'gemma4:e2b',
maxTreeFiles: 200,
timeout: cfg.get<number>('requestTimeout', 300) * 1000,
localBrainPath: activeBrain.localBrainPath,
secondBrainRepo: activeBrain.secondBrainRepo || '',
brainProfiles: profiles,
activeBrainId: activeBrain.id,
maxContextSize: cfg.get<number>('maxContextSize', 12000),
maxAutoSteps: cfg.get<number>('maxAutoSteps', 50),
chatTemperature: Math.min(2, Math.max(0, cfg.get<number>('chatTemperature', 0.3))),
dryRun: cfg.get<boolean>('dryRun', false),
multiAgentEnabled: cfg.get<boolean>('multiAgentEnabled', false),
memoryEnabled: cfg.get<boolean>('memoryEnabled', true),
memoryShortTermMessages: Math.max(0, cfg.get<number>('memoryShortTermMessages', 8)),
memoryMediumTermSessions: Math.max(0, cfg.get<number>('memoryMediumTermSessions', 5)),
memoryLongTermFiles: Math.max(0, cfg.get<number>('memoryLongTermFiles', 6)),
contextLength: Math.max(2048, cfg.get<number>('contextLength', 32768)),
maxOutputTokens: Math.max(256, cfg.get<number>('maxOutputTokens', 4096)),
contextSafetyMargin: Math.max(0, cfg.get<number>('contextSafetyMargin', 2048)),
contextOverflowPolicy: ((): IAgentConfig['contextOverflowPolicy'] => {
const v = cfg.get<string>('contextOverflowPolicy', 'stopAtLimit');
return v === 'truncateMiddle' || v === 'rollingWindow' ? v : 'stopAtLimit';
})(),
autoCompactHistory: cfg.get<boolean>('autoCompactHistory', true),
smallModelContextCap: Math.max(0, cfg.get<number>('smallModelContextCap', 0)),
autoContinueOnOutputLimit: cfg.get<boolean>('autoContinueOnOutputLimit', true),
maxAutoContinuations: Math.max(0, Math.min(10, cfg.get<number>('maxAutoContinuations', 4))),
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
))),
companyIntentClassifierModel: (cfg.get<string>('company.intentClassifierModel', '') || '').trim(),
companyDisableIntentClassifier: cfg.get<boolean>('company.disableIntentClassifier', false),
companyAutoSelectPipeline: cfg.get<boolean>('company.autoSelectPipeline', true),
companyIntentAlignmentMode: ((): 'off' | 'smart' | 'strict' => {
const v = (cfg.get<string>('company.intentAlignmentMode', 'smart') || 'smart').trim().toLowerCase();
return v === 'off' || v === 'strict' ? v : 'smart';
})(),
// ALIGNMENT_DEFAULT_MAX_ROUNDS 와 일치 — `intentAlignment` 모듈을 직접 import 하지 않은
// 이유는 config 가 features/ 아래 모듈을 의존하면 의도치 않은 순환 import 가 생기기 때문.
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
companyPixelOfficeBubbles: cfg.get<boolean>('company.pixelOffice.bubbles', true),
workflowMultiAgentMode: ((): 'auto' | 'always' | 'off' => {
const v = (cfg.get<string>('workflow.multiAgentMode', 'auto') || 'auto').trim().toLowerCase();
return v === 'always' || v === 'off' ? v : 'auto';
})(),
workflowAutoCtxFractionThreshold: Math.max(0.05, Math.min(0.95,
cfg.get<number>('workflow.autoCtxFractionThreshold', 0.30)
)),
chunkedSwitchTokens: Math.max(1000, cfg.get<number>('chunkedSwitchTokens', 50000)),
chunkedMaxSections: Math.max(1, Math.min(10, cfg.get<number>('chunkedMaxSections', 3))),
polishPersonaOverride: (cfg.get<string>('polishPersonaOverride', '') || '').trim(),
liveStreamTokens: cfg.get<boolean>('liveStreamTokens', true),
outputFormat: ((): 'plain' | 'markdown' => {
const v = (cfg.get<string>('outputFormat', 'plain') || 'plain').trim().toLowerCase();
return v === 'markdown' ? 'markdown' : 'plain';
})(),
chronicleAutoRecord: cfg.get<boolean>('chronicleAutoRecord', true),
lmStudioTopP: Math.max(0, Math.min(1, cfg.get<number>('lmStudio.sampling.topP', 0.9))),
lmStudioTopK: Math.max(0, cfg.get<number>('lmStudio.sampling.topK', 20)),
lmStudioMinP: Math.max(0, Math.min(1, cfg.get<number>('lmStudio.sampling.minP', 0.05))),
lmStudioRepeatPenalty: Math.max(1, Math.min(2, cfg.get<number>('lmStudio.sampling.repeatPenalty', 1.1))),
lmStudioShowStatsInBudget: cfg.get<boolean>('lmStudio.statsInBudget', true),
lmStudioDraftModel: (cfg.get<string>('lmStudio.draftModel', '') || '').trim(),
lmStudioLoad: {
flashAttention: cfg.get<boolean>('lmStudio.load.flashAttention', true),
gpuOffloadRatio: ((): 'max' | 'off' | number => {
const raw = (cfg.get<string>('lmStudio.load.gpuOffloadRatio', 'max') || 'max').trim().toLowerCase();
if (raw === 'max' || raw === 'off') return raw;
const n = Number(raw);
if (Number.isFinite(n)) return Math.max(0, Math.min(1, n));
return 'max';
})(),
offloadKVCacheToGpu: cfg.get<boolean>('lmStudio.load.offloadKVCacheToGpu', true),
keepModelInMemory: cfg.get<boolean>('lmStudio.load.keepModelInMemory', true),
useFp16ForKVCache: cfg.get<boolean>('lmStudio.load.useFp16ForKVCache', false),
evalBatchSize: Math.max(0, cfg.get<number>('lmStudio.load.evalBatchSize', 0)),
},
};
}
/**
* Config Validator: Validates the current configuration.
*/
export function validateConfig(): { valid: boolean; errors: string[] } {
const config = getConfig();
const errors: string[] = [];
// 1. Ollama URL Validation
try {
new URL(config.ollamaUrl);
} catch (e) {
errors.push(`Invalid Ollama URL: ${config.ollamaUrl}`);
}
// 2. Brain Path Validation
if (config.localBrainPath && !fs.existsSync(config.localBrainPath)) {
errors.push(`Brain path does not exist: ${config.localBrainPath}`);
}
return {
valid: errors.length === 0,
errors
};
}
// ─── 보안 정책 (Security Policy) ───
export const SECURITY_POLICY = {
allowedCommandPrefixes: [
'npm', 'yarn', 'pnpm', 'npx', 'node', 'ts-node', 'git', 'python', 'python3', 'pip', 'pip3',
'docker', 'docker-compose', 'ls', 'dir', 'cat', 'type', 'echo', 'print', 'cargo', 'go', 'rustc',
'java', 'javac', 'mvn', 'gradle', 'flutter', 'dart', 'pub', 'webpack', 'vite', 'esbuild', 'parcel',
'jest', 'mocha', 'vitest', 'cypress', 'tsc', 'vue-tsc',
],
forbiddenCommands: [
'rm -rf', 'rm-rf', 'del /f', 'format', 'mkfs', 'dd if=', ':(){ :|:& };:',
'wget http', 'curl http', 'sudo', 'chmod 777', 'chown root',
],
sensitiveFilePatterns: [
'.env', '.env.*', 'id_rsa', 'id_ed25519', '.gitconfig', '.npmrc', '.pypirc',
'credentials.json', 'service-account.json',
],
maxFileSize: 10 * 1024 * 1024,
maxContextFiles: 200,
};
export const EXCLUDED_DIRS = new Set([
'node_modules', '.git', '.vscode', 'out', 'dist', 'build',
'.next', '.cache', '__pycache__', '.DS_Store', 'coverage',
'.turbo', '.nuxt', '.output', 'vendor', 'target', '.astra'
]);