# locode 설계 메모 ## 프로젝트 개요 **locode** — Claude Code의 설계 철학을 가져와 구현한 에이전트 코딩 CLI. TypeScript + Ink(React-for-CLI) 기반. 백엔드는 OpenAI 호환 `/v1/chat/completions` 엔드포인트 사용 (Ollama, LM Studio, 클라우드 API 모두 지원). - 핵심 철학: "신뢰할 수 없고 느리고 비전/툴콜 지원이 불확실한 모델"이라는 현실에 맞춰 모든 가정을 비관적으로 재단. 로컬/클라우드 자동 감지로 프롬프트 분기. - Claude Code 플러그인 포맷을 직접 소비하는 하위호환 브리지 (`.claude-plugin/plugin.json`, commands/agents/skills/hooks/MCP) --- ## 아키텍처 결정 이유 ### Normal Screen + `` (alternate screen 폐지) 이전에는 alternate screen buffer + 인앱 가상 스크롤 + 마우스 트래킹을 직접 구현했으나 완전히 폐지. 이유: - **코드 복잡도**: 마우스 SGR-1006 파싱, 선택 영역, 스크롤 상태 관리가 App.tsx의 절반을 차지 - **터미널 호환성**: alternate screen은 SSH, tmux, Windows Terminal 등에서 파편화 심함 - **버그 발생률**: 마우스 크래시, 선택 텍스트 깨짐 등 이슈가 끝없이 발생 - **현재 방식**: Ink ``으로 완료된 히스토리를 한 번만 렌더링 → 터미널 스크롤백의 영구 부분. 재렌더링 없음. 마우스는 터미널 네이티브에 의존. ### 로컬/클라우드 모델 감지: `isSmallLocalModel()` - **이전**: `isLocalBackendURL(baseURL)` — URL이 localhost면 무조건 "로컬 모델" - **문제**: Ollama가 클라우드 라우팅 모델(`glm-5.2:cloud`, `qwen3.5:397b-cloud`)도 같은 localhost에서 서비스함. 이 모델들은 컨텍스트 128K~1M이고 툴콜도 안정적인데 로컬용 보수적 프롬프트가 적용됨. - **해결**: `isSmallLocalModel(baseURL, model)` = `isLocalBackendURL(baseURL) && !isCloudRoutedModelName(model)` — 모델명의 `:cloud`/`:-cloud` 태그로 구분. - **기본값**: `isLocal`의 기본값을 `true`에서 `false`(클라우드)로 변경. 명시적 지정이 없으면 보수적이 아닌 기본 프롬프트 사용. ### 컨텍스트 윈도우 기본값 분리 - **이전**: `DEFAULT_CONTEXT_WINDOW = 8192` (단일, 로컬 모델 기준) - **현재**: `DEFAULT_CONTEXT_WINDOW_LOCAL = 8192`, `DEFAULT_CONTEXT_WINDOW_CLOUD = 131072` — 클라우드/Ollama 클라우드 라우팅 모델은 128K~1M 컨텍스트를 가지므로 8192는 과도하게 보수적. ### 번인레이트(🔥) 계산 - **이전**: `outputTokens / 세션 경과 시간` — 사용자가 방치하면 번인레이트가 0에 수렴해서 의미 없음 - **현재**: `outputTokens / modelTimeMs` — 실제 모델 응답 시간으로 계산. "이 모델이 얼마나 빠르게 토큰을 뿜는가"를 정확히 반영. ### 파일 인코딩: CRLF/LF 혼재 - Windows 환경에서는 CRLF, Unix에서는 LF가 섞여 있음. `edit_file`/`multi_edit`은 매칭 전 LF로 정규화하고, 쓰기 전 원래 EOL을 복원. 이것 없이는 Windows에서 거의 모든 edit_file이 실패함. --- ## 핵심 파일 맵 - `src/ui/ink/index.tsx` — 진입점. alternate screen 없이 Ink render. cleanup 시 flush + 종료. - `src/ui/ink/App.tsx` — 메인 UI 컴포넌트. `` + 라이브 영역. 상태: starting→connecting→loading-models→model-select/session-select→input. - `src/ui/ink/ChatInput.tsx` — 커스텀 multiline 입력. Shift+Enter 줄바꿈, bracket paste, @멘션 fuzzy picker, IME 커서. - `src/agent/loop.ts` — 메인 에이전트 루프. 턴/스트리밍/툴콜/컴팩션/서브에이전트/병렬 툴 배치/반복 루프 감지. - `src/agent/session.ts` — Session 객체, 통계, 상태, mutation gate. - `src/agent/systemPrompt.ts` — 시스템 프롬프트 빌더 (`isSmallLocalModel` 기반 로컬/클라우드 분기). - `src/config/defaults.ts` — 모든 기본값. `isSmallLocalModel()`, `isCloudRoutedModelName()`, `DEFAULT_CONTEXT_WINDOW_LOCAL/CLOUD` 등. - `src/toolcalling/` — native 어댑터, fallback 파서/프롬프트, partialJson 복구, resolve (Ollama 빈키 복구 포함). --- ## 설정 기본값 | 설정 | 기본값 | 비고 | |---|---|---| | `DEFAULT_CONTEXT_WINDOW_LOCAL` | 8192 | 작은 로컬 모델 폴백 | | `DEFAULT_CONTEXT_WINDOW_CLOUD` | 131072 | 클라우드/클라우드 라우팅 폴백 | | `DEFAULT_MAX_ITERATIONS` | **300** | 50→100→300 상향 | | `DEFAULT_MAX_OUTPUT_TOKENS` | **131072** | 128K. GLM 등 1M 컨텍스트 모델 대응 | | `DEFAULT_MAX_RETRIES` | 0 | SDK 지수 백오프 | | `DEFAULT_AUTO_COMPACT_THRESHOLD` | 0.85 | | | `DEFAULT_REQUEST_TIMEOUT_MS` | 180,000 | 3분 | | `DEFAULT_SUBAGENT_TIMEOUT_MS` | 600,000 | 10분 | | `MAX_EMPTY_RESPONSE_RETRIES` | **3** | 1→3 상향 | | `MAX_SUBAGENT_DEPTH` | 1 | 서브에이전트 중첩 금지 | | `MAX_PRESERVED_TAIL_MESSAGES` | 8 | 컴팩션 시 보존 | | `MAX_PRESERVED_TAIL_FRACTION` | 0.3 | 컴팩션 시 보존 비율 | | `MAX_RETAINED_IMAGES` | 2 | 히스토리 이미지 보존 | --- ## 트러블슈팅 힌트 - **"자꾸 에러"**: 주요 원인은 max_tokens 잘림 → malformed 툴콜. 동적 max_tokens + CRLF 제어문자 이스케이프로 해결됨. - **CRLF edit_file 매칭 버그**: LF 정규화 공간에서 매칭, 쓰기 전 원래 EOL 복원으로 해결됨. - **"Paused after N steps"**: `locode config set maxIterations ` (기본 300) - **클라우드 모델 빈 응답**: `MAX_EMPTY_RESPONSE_RETRIES=3`으로 재시도 - **로컬/클라우드 프롬프트 분기**: `isSmallLocalModel(baseURL, model)` — Ollama 클라우드 라우팅 모델(`:cloud` 태그)은 localhost여도 클라우드 프롬프트 사용 - **반복 루프 감지**: `detectRepetitionLoop()` — 스트리밍 텍스트에서 짧은 반복 패턴 감지 시 중단 - **번인레이트(🔥)**: `outputTokens ÷ modelTimeMs` 기준 (세션 경과 시간이 아닌 실제 모델 응답 시간) - **IPv6 localhost**: `isLocalBackendURL()`은 `[::1]` 형식(WHATWG URL 직렬화)도 인식 --- ## 의존성 제약 - **marked는 15에 고정**. `marked-terminal@7.3.0`의 peer가 `marked >=1 <16`이고 marked-terminal 업데이트가 없음. marked 16+로 올리려면 marked-terminal을 교체하거나 peer를 강제해야 함. - **typescript는 7.x (네이티브 컴파일러 포팅)**. 프로젝트는 `tsc` CLI로 타입체크만 하고 programmatic API를 안 씀 → 네이티브 포트로 안전하게 이전. 빌드는 tsup/esbuild라 tsc와 무관. 플랫폼별 `@typescript/typescript-*` 바이너리가 optional dep으로 붙음. - **wrap-ansi는 10.x (ink와 동일)**. `renderMarkdown`이 히스토리 출력을 터미널 폭으로 하드랩할 때 사용 — ink 내부 래핑과 같은 string-width v8을 공유해야 폭 계산이 어긋나지 않음. v10은 타입 내장(앰비언트 선언 불필요). - `allowScripts`에 트리에 실제로 존재하는 esbuild 버전을 모두 나열해야 함 (현재 `0.27.2` = tsup/vite, `0.28.1` = tsx). 빠지면 postinstall 경고. ## TODO - LSP 실서버 통합 테스트 (실제 tsserver/pyright 띄워서 검증) - task store 영속화 (세션에 task 저장)