SLOPSHOPPER

auto-model

요청마다 맞는 모델이 처리해요. 메인(Opus)은 그대로 두고 단순한 일은 Sonnet·Haiku 일꾼에게 맡겨서, 기억(프롬프트 캐시)을 안 깨고 사용료를 아껴요. 쉬고 온 뒤 압축은 Sonnet 요약, 아낀 만큼 테리 카드에 표시

newguardcommandtoastpromptmodel
v0.5.4no licenseupdated 2026-10-10d3nims/d3nim-claude-mods/plugins/auto-model
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-model
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /auto-model ⎿ auto-model: 게이지 환산 보정 중 (표본 5시간 0/5 · 주간 0/5) ⎿ auto-model: (같은 계정을 다른 사람·다른 PC 와 같이 쓰면 게이지 환산은 어림값이에요. 이 PC 합계 % 는 이 컴퓨터 숫자만 써서 정확해요) ⎿ auto-model: auto-model 켜짐 · 자동 전환: 켜짐 ⎿ auto-model: 캐시 식는 시간 60분 · 압축 제안 15만 토큰 이상 ⎿ auto-model: 이 세션에서 아직 모델 호출이 없어요 ⎿ auto-model: (/auto-model log: 최근 판단 기록) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

d3nim-claude-mods

d3nim 팀이 같이 쓰는 Claude Code mods 모음입니다.

설치

Claude Code 안에서:

/plugin marketplace add d3nims/d3nim-claude-mods
/plugin install usage-meter@d3nim-claude-mods
/plugin install auto-model@d3nim-claude-mods
/reload-plugins

터미널에서 해도 됩니다:

claude plugin marketplace add d3nims/d3nim-claude-mods
claude plugin install usage-meter@d3nim-claude-mods

공개 저장소라 GitHub 로그인 없이 받을 수 있습니다.

업데이트

/plugin marketplace update d3nim-claude-mods
/reload-plugins

usage-meter

프롬프트 위에 5시간 · 주간 · 대화 사용량을 보여 주고, 80% · 90% 에서 알려 줍니다.

/terry

입력하는 동안 꼬리를 흔들고, 엔터를 치면 응답이 끝날 때까지 달리고, 끝나면 헥헥거리다 조는 테리

추론 강도에 따라 달리기가 달라져요

응답하는 동안 테리는 지금 추론 강도(/effort)에 맞춰 움직입니다. max 에선 빨간 망토를 휘날리며 날아요. /terry run max 처럼 강도를 붙이면 20초 동안 미리 볼 수 있어요.

low 걷기, medium 빨리 걷기, high 달리기, xhigh 전력 질주, max 망토 달고 날기

Claude 가 하는 일에 따라 움직여요

파일을 읽거나 찾을 땐 킁킁거리고, 명령을 실행하거나 파일을 고칠 땐 땅을 파고(오래 팔수록 뒤에 흙더미가 쌓여요), 웹에서 가져올 땐 막대기를 물어 와요. 허락을 기다리면 말풍선에 ? 를 띄우고, 요청이 실패하거나 멈추면 고개를 떨구고 시무룩해져요. /terry sniff · dig · fetch · ask · sad 로 미리 볼 수 있어요.

읽기·검색 킁킁, 실행·수정 땅 파기, 웹 물어 오기, 허락 대기 말풍선, 실패 시무룩

이럴 때도 반응해요

  • 테스트가 통과하면 뛰어올라 원반을 물고, 실패하면 고개를 떨구고 꼬리를 말아요.
  • 5시간 한도를 다 쓰면 문 앞에서 기다리다가, 풀리는 순간 짖으며 알려 줘요.
  • 하루 첫 입력엔 기지개를 켜며 하품하고, 저녁 6시~7시 반엔 가끔 밥을 먹어요.
  • 카드의 🐾 를 누르거나 /terry pet 하면 좋아해요. 쓰다듬은 횟수는 /terry stats 에 남아요.
  • 설날·추석엔 복주머니와 송편이, 설치한 지 1년 되는 날엔 케이크가 놓여요.
  • /terry catch · droop · door · stretch · eat 로 미리 볼 수 있어요.

/flame1

5시간 · 주간 · 대화 사용량을 파란 불꽃 높이로 보여 주는 밴드

명령내용
/flame1파란 불꽃 사용량 밴드 + 모델 카드
/terry입력·응답에 반응하는 베들링턴 테리어, 이번 요청의 시간·토큰 카드, 사용량 표
/terry run20초 동안 달리는 모습 미리보기 (sit wag bark happy sleep, stop 으로 끝내기)
/terry run max추론 강도별 달리기 미리보기 (low medium high xhigh max, middle · 중간 도 됨)
/terry dig도구별 동작 미리보기 (sniff dig fetch ask sad)
/terry catch상황별 동작 미리보기 (catch 테스트 통과, droop 테스트 실패, door 한도 대기, stretch 하루 첫 입력, eat 저녁)
/terry pet테리 쓰다듬기 (카드의 🐾 를 눌러도 됨)
/terry pane테리를 옆 창에 따로 크게 띄우기 (카드의 🐕 를 눌러도 됨)
/terry stats오늘의 기록: 요청 수, 토큰, 가장 오래 걸린 요청, 테리가 달린 거리, 쓰다듬은 횟수
/terry braille · /terry quad그림 방식 바꾸기 (/flame1 도 같음). 테리는 점자(braille)가 기본이고 점 하나하나 다듬은 그림이에요. quad 는 블록용으로 그렸던 예전(1.0.2) 그림으로 보여요
/terry help사용법

테리는 평소엔 앉아서 숨 쉬며 꼬리를 살랑이다가, 입력하는 동안 혀를 내밀고 꼬리를 흔들고, 엔터를 치면 응답이 끝날 때까지 달리고, 다 끝나면 헥헥거리고, 3분 동안 조용하면 졸아요. 하늘은 실제 시계를 따라 낮엔 해, 밤엔 달과 별이 뜨고, 날짜를 따라 봄엔 꽃잎, 여름밤엔 반딧불, 가을엔 낙엽, 겨울엔 눈이 내려요. 12/31 과 1/1 밤엔 불꽃놀이도 터져요.

색이 이상하게 보이면

테리가 청록색이나 보라색으로 보이거나 흙길이 회색이면, 터미널이 트루컬러를 알리지 않아 256색으로 줄여 그려진 것입니다. 셸 설정(~/.bashrc 등)에 아래 줄을 넣고 Claude Code 를 다시 켜 주세요.

export COLORTERM=truecolor

Claude 밖에서 테리 보기

node docs/preview/terry-view.mjs   # ←→ 동작, e 강도, m 그림 방식, w 너비, t 시각, s 계절, y 명절, space 느리게, q 끝내기

auto-model

요청마다 맞는 모델이 처리해요. 메인(Opus)은 그대로 두고 단순한 일은 Sonnet·Haiku 일꾼에게 맡겨서, 기억을 안 깨고 사용료를 아껴요. 팀장을 바꾸는 게 아니라 일마다 맞는 팀원에게 배정하는 셈이에요. 메인 모델을 바꾸지 않는 이유: 캐시는 모델마다 따로이고 마지막 호출 뒤 1시간이 지나면 식어서, 긴 대화에서 함부로 모델을 바꾸면 대화 전체를 다시 저장하느라 오히려 비싸집니다. usage-meter 와 같이 쓰면 테리 카드에 표시됩니다.

  • 쉰 뒤 압축 제안: 1시간 넘게 쉬었고 대화가 15만 토큰 이상이면, 다음 요청이 대화 전체를 다시 쓴다는 걸 알리고 압축을 제안합니다 ([압축] [계속]). 이때 [압축] 은 Sonnet 이 요약합니다(시험에서 Opus 와 같은 품질에 절반 값).
  • 작업 중 압축 알림: 대화가 85% 차면 "지금은 기억이 살아 있어서 싸게 압축돼요" 를 띄웁니다 ([압축] [나중에], 나중에는 5%p 더 찰 때까지 안 물음). 이때는 원래 모델이 압축합니다.
  • 압축에 사용자 메시지 원문: 카드의 [압축] 으로 하는 압축에는 사용자가 보낸 메시지 원문(긴 붙여넣기는 앞부분만)을 붙여 승인·금지 범위가 흐려지지 않게 합니다. 직접 친 /compact 와 자동 압축은 그대로입니다.
  • 모델 고르기: 요청마다 판단하되, 바꾸는 쪽이 확실히 쌀 때만 바꿉니다. 맞장구는 그대로, "ㅇㅋ 진행해" 같은 승인·진행 지시는 무거운 일로 봅니다. 기억을 묻는 짧은 질문의 전환은 지금은 "켰다면"만 기록합니다 (/auto-model light on 으로 켜기).
  • 서브에이전트: 모델을 지정하지 않은 검색·탐색 서브에이전트는 Haiku 로 보냅니다.
  • 일꾼: 판단이 필요 없는 일(목적지와 내용이 분명한 쓰기는 Sonnet, 찾기·읽기는 Haiku)은 Opus 를 부르지 않고 일꾼 서브에이전트가 최근 몇 턴만 받아 처리합니다. 일꾼의 답이 대화 끝에 붙어서 Opus 의 캐시는 그대로입니다. 기본은 기록만(shadow), /auto-model worker on 으로 켜기. @@ 요청(Sonnet) · @@h 요청(Haiku) 은 손으로 바로 일꾼에게.
  • 얼마나 아꼈나: 일꾼·Sonnet 압축이 처리하면 답 끝이나 토스트에 "실제 약 $A · Opus였다면 약 $B → 약 $C 절약" 이 붙고, 카드 아래에 💰 이 대화 약 N% 아낌 (= 이 대화에서 아낀 값 ÷ (이 대화가 실제 쓴 값 + 아낀 값)) 이 보입니다. /auto-model 상세에는 이 PC 합계(오늘·7일)와 5시간·주간 게이지로 환산한 값도 참고로 나옵니다. 모두 이 컴퓨터의 숫자만 씁니다: 다른 PC 의 비용은 합칠 수 없어서 PC 별 표시이고, 같은 계정을 다른 사람·다른 PC 와 같이 쓰면 게이지 환산은 어림값입니다 (내 사용량 대비 % 는 그래도 정확).
  • 늘 보이게: 카드 모델 줄에 🔄 (바꿔서 씀) 📌 (고정) ✋ (/model 로 직접 골라 멈춤). 프롬프트 앞에 ~ 를 붙이면 그 요청은 건너뜁니다.
/plugin install auto-model@d3nim-claude-mods
명령내용
/auto-model지금 상태
/auto-model off · on전부 끄고 켜기
/auto-model pin opus · unpin모델 고정 / 풀기
/auto-model worker on · shadow · off일꾼 (기본 shadow: 기록만)
/auto-model compact · /terry compact카드의 [압축] 과 같음 (마우스 클릭이 안 될 때; 쉬고 온 뒤면 Sonnet 요약)
/auto-model log최근 판단 기록 (요청 앞부분, 이유, 대화 크기, 캐시, 사용량, shadow 손익, 놓친 일꾼 후보)
/auto-model preview압축 제안 미리 보기 1분
/auto-model warn 85 · off작업 중 압축 알림 기준(%)

jev

입력이 "기존 화면처럼 해 달라"(G-1)나 "직전 결과가 틀렸다"(G-3)인지 로컬 판정기로 0.1초에 가려, Claude 가 기준을 바꾸거나 추측으로 재시도하지 않게 짧은 알림을 넣습니다. Ollama(bge-m3)가 필요하고, 처음엔 기록만 하는 shadow 모드로 동작합니다.

/plugin install jev@d3nim-claude-mods

준비·모드·로그는 plugins/jev/README.md.

도구

  • tools/paste-hotkey: SSH 로 붙어 쓰는 Claude Code 에 Alt+V 로 캡처 이미지를 붙여넣기 (SSH 를 여는 쪽 Windows PC 에 설치)
Source 3 files
hooks/register.js 1086 lines
1// auto-model: 프롬프트 캐시를 아끼며 모델을 고른다.
2//
3// 1단계: 한참 쉬어서 캐시가 식었고 대화가 길면, 다음 요청이 대화 전체를 다시 캐시에 쓴다는 걸 알리고 /compact 를
4// 제안한다. 제안은 usage-meter 가 테리 카드 아래에 그린다 ($.state 의 auto-model / hint).
5//   - 캐시는 마지막 호출 뒤 TTL(Claude Code 는 1시간)이 지나면 식는다. 그 뒤 첫 요청은 대화 전체를 다시 쓴다.
6//   - /compact 도 대화 전체를 한 번 읽어 요약하므로 그 순간 비용이 사라지지는 않는다. 아끼는 건 그 뒤의 요청들과
7//     다음에 또 쉬고 돌아올 때다. 압축이 실제로 든 값은 기록해 두고 /auto-model 에서 보여 준다.
8//
9// 2단계: 요청마다 메인 대화의 모델을 고른다 (turn.step 의 첫 단계에서 정하고 그 턴 동안 유지).
10//   - 캐시는 모델마다 따로라서, 긴 대화에서 캐시가 따뜻할 때 모델을 바꾸면 대화 전체를 새 모델이 다시 쓴다.
11//     그래서 '가벼운 요청'이라도 바꾸는 쪽의 어림값(다시 쓰기 + 답)이 그대로 두는 쪽보다 확실히 쌀 때만 바꾼다.
12//     실제로는 캐시가 식은 순간이나 대화가 짧을 때다.
13//   - 안전장치: 애매하면 그대로 / 메인은 Sonnet 까지만 내림 (Haiku 는 검색·탐색 서브에이전트만, 설정으로 메인 허용)
14//     / 싼 모델에서 결과를 거부하거나 도구 오류가 거듭되면 다음 턴에 원래 모델로 / 카드에 늘 표시, 명령 하나로 고정·끄기,
15//     /model 로 직접 바꾸면 그 세션은 자동 중지 / 결정마다 기록 (/auto-model log).
16//   - 판단: 로컬 규칙이 먼저, 애매하고 바꿀 만할 때만 작은 모델에게 묻는다 ($.model.classify). 밖으로 보내지 않는다.
17//   - 프롬프트 앞에 ~ 를 붙이면 그 요청은 원래 모델 그대로 (~ 는 떼고 보낸다).
18//
19// 3단계: 판단이 필요 없는 일(쓰기는 Sonnet, 찾기·읽기는 Haiku)은 Opus 대신 일꾼 서브에이전트가 맡는다 (기본은 shadow: 기록만).
20//   '@@ 요청' / '@@h 요청' 은 손으로 Sonnet / Haiku 일꾼에게.
21//
22// /auto-model                : 지금 상태
23// /auto-model on | off       : 전부 켜고 끄기 (제안과 자동 전환)
24// /auto-model auto | manual  : 자동 전환만 켜고 끄기
25// /auto-model pin <opus|sonnet|haiku> · unpin : 모델 고정 / 풀기
26// /auto-model haiku on | off : 메인 대화도 Haiku 까지 내릴지 (기본 off)
27// /auto-model worker on | shadow | off : 일꾼 (기본 shadow)
28// /auto-model light on | off : 기억 확인 질문('어디였지?')도 실제로 내릴지 (기본 off: '켰다면'만 기록하는 shadow)
29// /auto-model log            : 최근 판단 기록 (전환·보류 이유, 대화 크기, 캐시, 사용량)
30// /auto-model ttl <분> · min <천 토큰> : 캐시 식는 시간 (기본 60) · 제안할 대화 크기 (기본 150)
31// /auto-model compact [남길 내용] · dismiss · preview : 압축 / 이번 제안 닫기 / 제안 미리 보기 1분
32// /auto-model warn <퍼센트> | off : 작업 중 대화가 이만큼 차면 압축 권하기 (기본 85)
33
34import { MODELS, appendCost, familyOf, isRejection, isSearchy, missedWorkerOf, opusWouldCompact, opusWouldWorker, prettyName, promptHead, ruleOf, taskOf, turnCost, usageCost, workerEstimate } from './route.js'
35
36const HINT = { plugin: 'auto-model', key: 'hint' }
37const ROUTE = { plugin: 'auto-model', key: 'route' }
38const MEMORY = { plugin: 'auto-model', key: 'memory' }
39const SAVING = { plugin: 'auto-model', key: 'saving' }
40const ASK = { plugin: 'auto-model', key: 'ask' }
41let askHandled = true // 부탁한 압축이 시작됐는지 (안 되면 3초 뒤 직접)
42
43const DEFAULTS = { enabled: true, ttlMinutes: 60, minTokens: 150000, route: 'auto', pin: null, mainHaiku: false, lightSwitch: false, worker: 'shadow', warnPct: 85 }
44let settings = { ...DEFAULTS }
45// 메인 대화의 마지막 모델 호출: 시각, 다음 요청이 다시 보낼 크기, 답한 모델
46let last = null
47let dismissedFor = null // 이 시각의 호출에 대한 제안은 닫았음
48let shownMinutes = -1
49let previewUntil = 0 // /auto-model preview 가 보여 주는 동안은 판단을 쉰다
50let shownKind = null // 떠 있는 제안: 'cold' (쉬고 옴) | 'warm' (작업 중 대화가 참)
51let shownPct = -1
52let warmSnoozePct = 0 // 작업 중 제안에서 '나중에' 를 누르면, 이만큼(+5%p) 더 찰 때까지 다시 안 묻는다
53let compactMode = null // 우리 [압축] 버튼이 시작한 압축의 종류 (session.compact 훅이 읽는다)
54let compactModeUntil = 0
55// (자기 플러그인이 시작한 압축에는 자기 session.compact 훅이 돌지 않는다: 그래서 Sonnet 요약은 카드의 [압축] 버튼, 즉
56//  usage-meter 가 압축을 시작하고 auto-model 이 그걸 가로챌 때만 된다. /auto-model compact 로 친 압축은 원래 모델이 한다)
57let compactHandled = false
58
59// 2단계 (세션 동안)
60const warmAt = {} // 모델 계열(opus/sonnet/haiku) -> 그 모델이 메인 대화에 마지막으로 답한 시각
61let current = null // 메인 대화에 마지막으로 쓴 모델
62let sessionBase = null // 세션에 설정된 모델 (/model): 바뀌면 사람이 고른 것
63let stopped = false // 사람이 /model 로 바꿔서 이 세션은 자동 중지
64let pending = null // 방금 입력한 요청 { text, skip }
65let cooldown = 0 // 되돌아온 뒤 몇 턴은 다시 내리지 않는다
66let errors = 0 // 싼 모델 턴의 도구 오류 수
67const turnModel = new Map() // turnId -> 그 턴에 쓰는 모델
68let decisions = [] // 최근 판단 (store 'decisions' 에도)
69let route = null // 카드에 보이는 상태
70let sessionId = null
71let lastAnswer = '' // 메인 대화의 직전 답 끝부분 (짧은 긍정이 승인인지 볼 때)
72// 3단계 일꾼
73let pendingWork = null // 방금 입력한 요청이 일꾼 후보면 { task, model, text, manual }
74const workerWaiters = new Map() // 일꾼 agentId -> resolve(turn.complete)
75const workerIds = new Set() // 우리가 띄운 일꾼: 끝나면 엔진이 결과를 메인 대화에 또 배달하는데, 이미 답으로 붙였으니 버린다
76const WORKER_WAIT_MS = 10 * 60000
77const turnTools = new Map() // 메인 턴 turnId -> 그 턴의 도구 호출 [{ tool, isError }] (사후 판정용)
78let liveTurnId = null
79
80// 판단 근거를 세션 상태에 적어 둔다 (/reload-plugins 는 모듈 변수를 비우지만 $.state 는 남는다)
81async function saveMemory($) {
82  if (!sessionId) return
83  try {
84    await $.state.set(MEMORY, { sessionId, last, warmAt: { ...warmAt }, current, sessionBase, stopped, cooldown, lastAnswer })
85  } catch {}
86}
87// 같은 세션의 기록이면 되살린다 (다른 세션이면 모르는 채로: 모르면 그대로 둔다)
88async function restoreMemory($) {
89  try {
90    sessionId = await $.session.id()
91    const { value } = await $.state.get(MEMORY)
92    if (!value || value.sessionId !== sessionId) return
93    last = value.last
94    Object.assign(warmAt, value.warmAt || {})
95    current = value.current
96    sessionBase = value.sessionBase
97    stopped = !!value.stopped
98    cooldown = value.cooldown || 0
99    lastAnswer = value.lastAnswer || ''
100  } catch {}
101}
102
103// 응답 하나의 사용량에서, 다음 요청이 다시 보낼 대화 크기 (입력 전부 + 이번 출력)
104const contextOf = u => (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0)
105// 1시간 캐시에 대화를 다시 쓰는 값 (답 값은 빼고)
106const rewriteUsd = (model, tokens) => turnCost(model, tokens, false, 0)
107
108const tokensText = n => (n >= 10000 ? Math.round(n / 10000) + '만' : n >= 1000 ? (n / 1000).toFixed(0) + '천' : Math.round(n)) + ' 토큰'
109const idleText = min => (min >= 60 ? Math.floor(min / 60) + '시간' + (min % 60 ? ' ' + (min % 60) + '분' : '') : min + '분')
110const timeText = at => new Date(at).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
111const usd = n => '$' + n.toFixed(n >= 1 ? 2 : 3)
112
113async function setHint($, value) {
114  try {
115    await $.state.set(HINT, value)
116  } catch {}
117}
118async function setRoute($, value) {
119  try {
120    await $.state.set(ROUTE, value)
121  } catch {}
122}
123
124// ---- 1단계: 쉰 뒤 압축 제안 ----
125
126async function check($) {
127  const now = await $.clock.now()
128  if (previewUntil) {
129    if (now < previewUntil) return
130    previewUntil = 0
131    await clearHint($)
132  }
133  if (!settings.enabled || !last) return
134  const idle = now - last.at
135  const cold = idle >= settings.ttlMinutes * 60000
136  let live = null // 지금 대화 크기 (압축 뒤엔 기억해 둔 값보다 이게 맞다)
137  try {
138    live = (await $.session.usage()).context.tokens
139  } catch {}
140  const size = live > 0 ? Math.min(last.tokens, live) : last.tokens
141  // 쉬고 옴: 캐시가 식었고 대화가 길다 → 압축하면 Sonnet 이 요약 (같은 품질에 절반 값, 시험으로 확인)
142  if (cold && size >= settings.minTokens && dismissedFor !== last.at) {
143    const idleMinutes = Math.floor(idle / 60000)
144    if (shownKind === 'cold' && idleMinutes === shownMinutes) return
145    shownKind = 'cold'
146    shownMinutes = idleMinutes
147    await setHint($, { kind: 'cold', id: last.at, idleMinutes, tokens: size, rewriteUsd: rewriteUsd(last.model, size), model: last.model })
148    return
149  }
150  // 작업 중: 대화가 많이 찼다 → 지금은 캐시가 살아 있어 원래 모델이 싸게 압축한다
151  if (!cold && settings.warnPct > 0) {
152    let pct = null
153    try {
154      pct = (await $.session.usage()).context.percent
155    } catch {}
156    if (pct != null && pct >= settings.warnPct && pct >= warmSnoozePct) {
157      if (shownKind === 'warm' && shownPct === pct) return
158      shownKind = 'warm'
159      shownPct = pct
160      shownMinutes = 0
161      await setHint($, { kind: 'warm', id: last.at, percent: pct, idleMinutes: 0, tokens: last.tokens, rewriteUsd: 0, model: last.model })
162      return
163    }
164  }
165  if (shownKind) await clearHint($)
166}
167
168async function clearHint($, onlyKind) {
169  if (onlyKind && shownKind !== onlyKind) return
170  previewUntil = 0
171  if (shownMinutes < 0 && !shownKind) return
172  shownMinutes = -1
173  shownKind = null
174  shownPct = -1
175  await setHint($, null)
176}
177
178// 압축: 명령이나 버튼이 도는 동안엔 엔진이 압축을 거절해서('턴이 도는 중'), 명령이 끝난 직후에 시작한다.
179// (자기 플러그인의 압축 훅은 자기 호출엔 돌지 않으므로, 쓴 토큰 기록도 여기서 한다)
180async function compactNow($, instructions, mode) {
181  const kind = mode || shownKind || 'warm'
182  await clearHint($)
183  void (async () => {
184    await $.clock.sleep(300)
185    compactMode = kind
186    compactHandled = false
187    try {
188      const r = await $.session.compact(instructions ? { instructions } : undefined)
189      if (r && r.skip) {
190        $.ui.toast('auto-model: 압축하지 않았어요 — ' + r.skip)
191        return
192      }
193      const before = r && r.tokensBefore
194      const after = r && r.tokensAfter
195      if (last && after) last = { ...last, tokens: after }
196      if (!compactHandled) await logCompaction($, { trigger: 'plugin', mode: kind, before, after, usage: r && r.usage })
197      $.ui.toast('auto-model: 압축했어요' + (before && after ? ` (${tokensText(before)} → ${tokensText(after)})` : ''))
198    } catch (err) {
199      $.ui.toast('auto-model: 압축을 못 했어요 (' + String((err && err.message) || err).slice(0, 120) + ') · /compact 를 직접 입력해 주세요')
200    } finally {
201      compactMode = null
202    }
203  })()
204  return kind === 'cold' ? '압축을 시작할게요 (쉬고 와서 캐시가 식었으니 Sonnet 이 요약해요. 끝나면 알려 드려요)' : '압축을 시작할게요 (끝나면 알려 드려요)'
205}
206
207// 오늘·이번 주 절약 누적 (API 환산 어림값). 일꾼이 실패해 쓴 값은 손해로 빼서 순절약으로 보인다. shadow 는 '가능'으로 따로
208const dayKey = at => new Date(at).toLocaleDateString('sv-SE') // YYYY-MM-DD (컴퓨터 시간대)
209async function addSaving($, kind, amount) {
210  // 이 대화의 순절약 (카드의 '이 대화 약 N% 아낌')
211  if (kind === 'worker' || kind === 'compact') sessionNet += amount
212  else if (kind === 'loss') sessionNet -= amount
213  try {
214    const now = await $.clock.now()
215    const all = (await $.store.get('savings')) || {}
216    const d = all[dayKey(now)] || { net: 0, workers: 0, compactions: 0, losses: 0, potential: 0, shadowTurns: 0 }
217    if (kind === 'worker') (d.net += amount), d.workers++
218    else if (kind === 'compact') (d.net += amount), d.compactions++
219    else if (kind === 'loss') (d.net -= amount), (d.losses += amount)
220    else if (kind === 'potential') (d.potential += amount), d.shadowTurns++
221    all[dayKey(now)] = d
222    for (const k of Object.keys(all).sort().slice(0, -14)) delete all[k] // 2주치만
223    await $.store.set('savings', all)
224  } catch {}
225  if (kind !== 'potential') {
226    costSavedAt = 0
227    await flushCost($)
228    await publishSaving($)
229  }
230}
231// 오늘 이 PC 사용량 대비 아낀 비율 (%): 순절약 / (실제 쓴 값 + 순절약). 쓴 값이 $1 도 안 되면(설치 직후 등) 비율이 크게 튀어서
232// 아직 집계 중(null), 아낀 게 없어도 null
233const myShare = (spent, net) => (spent >= 1 && net > 0 ? (net / (spent + net)) * 100 : null)
234async function savingsText($) {
235  let all = {}
236  try {
237    all = (await $.store.get('savings')) || {}
238  } catch {}
239  const now = await $.clock.now()
240  const today = all[dayKey(now)]
241  const week = Object.entries(all).filter(([k]) => k >= dayKey(now - 6 * 86400000))
242  const wsum = week.reduce((a, [, d]) => a + d.net, 0)
243  const wpot = week.reduce((a, [, d]) => a + (d.potential || 0), 0)
244  if (!today && !week.length) return null
245  const t = today || { net: 0, workers: 0, compactions: 0, losses: 0, potential: 0 }
246  const per = await dollarsPerPct($)
247  const spent = (t.spent || 0) + pendingSpent
248  const share = myShare(spent, t.net)
249  const mine = share != null ? `오늘 약 ${Math.round(share)}% 아낌 (이 PC 의 모든 세션이 실제로 ${usd(spent)} 씀) · ` : ''
250  const gauge = per.five_hour && per.seven_day ? `게이지로 5시간 약 ${(Math.max(0, t.net) / per.five_hour).toFixed(1)}% · 주간 약 ${(Math.max(0, wsum) / per.seven_day).toFixed(1)}% · ` : `게이지 보정 중 (표본 5시간 ${per.n5}/5 · 주간 ${per.n7}/5) · `
251  return '이 PC 합계 · ' + mine + gauge + `오늘 약 ${usd(Math.max(0, t.net))} 절약 (일꾼 ${t.workers}번 · 압축 ${t.compactions}번` + (t.losses ? ` · 실패 손해 ${usd(t.losses)} 뺌` : '') + `) · 최근 7일 약 ${usd(wsum)}` +
252    (wpot ? ` · 일꾼을 켰다면 약 ${usd(wpot)} 더 절약 가능 (shadow)` : '') + ' (API 환산 어림값. 게이지 % 는 이 컴퓨터 세션들의 비용과 게이지 변화로 어림한 1% 당 금액으로 환산)'
253}
254
255// ---- 게이지 보정: 'API 환산 $' 를 사용자가 매일 보는 5시간·주간 게이지 % 로 ----
256// 구독 한도의 % 기준은 공개되지 않아서, 이 컴퓨터의 모든 세션이 쓴 API 환산 비용과 게이지 % 의 변화를 같이 보며 '1% 당 약 $X' 를
257// 창(5시간·주간)마다 따로 어림한다. 세션마다 자기 누적 비용을 store 에 적고(cost:<세션>), 합쳐서 본다. 창이 초기화되면(resetsAt 이
258// 바뀌면) 기준점을 새로 잡아 초기화 직후의 급락을 사용으로 읽지 않는다. 다른 컴퓨터의 세션은 못 보니 1% 당 값이 작게 나올 수 있다.
259let sessionCost = 0 // 이 세션이 쓴 API 환산 비용 누적 (메인 + 서브에이전트 + 일꾼 + 압축)
260let sessionNet = 0 // 이 세션의 순절약 (일꾼·Sonnet 압축 절약 − 일꾼 실패 손해)
261let pendingSpent = 0 // 아직 오늘 기록(savings[날짜].spent)에 더하지 않은 비용
262let costSavedAt = 0
263const calib = { five_hour: null, seven_day: null } // 기준점 { resetsAt, pct, cost }
264async function addCost($, model, usage) {
265  if (!usage) return
266  const c = usageCost(model || usage.model, usage)
267  sessionCost += c
268  pendingSpent += c
269}
270async function flushCost($) {
271  if (!sessionId) return
272  try {
273    const now = await $.clock.now()
274    if (now - costSavedAt < 20000) return
275    costSavedAt = now
276    await $.store.set('cost:' + sessionId, { cum: sessionCost, net: sessionNet, at: now })
277    if (pendingSpent > 0) {
278      // 오늘 이 컴퓨터에서 실제로 쓴 값 (카드의 '내 사용량 대비 %' 의 분모)
279      const all = (await $.store.get('savings')) || {}
280      const d = all[dayKey(now)] || { net: 0, workers: 0, compactions: 0, losses: 0, potential: 0, shadowTurns: 0 }
281      d.spent = (d.spent || 0) + pendingSpent
282      pendingSpent = 0
283      all[dayKey(now)] = d
284      await $.store.set('savings', all)
285    }
286    const idx = (await $.store.get('cost-index')) || []
287    if (!idx.includes(sessionId)) await $.store.set('cost-index', [...idx, sessionId].slice(-40))
288  } catch {}
289}
290// /reload-plugins 뒤에도 이 세션의 누적을 이어 간다 (안 그러면 0 부터 다시 세어 store 의 누적을 덮었다)
291async function restoreCost($) {
292  if (!sessionId) return
293  try {
294    const c = await $.store.get('cost:' + sessionId)
295    if (c) (sessionCost = Math.max(sessionCost, c.cum || 0)), (sessionNet = c.net || 0)
296  } catch {}
297}
298async function totalCost($) {
299  let sum = 0
300  try {
301    const idx = (await $.store.get('cost-index')) || []
302    for (const id of idx) {
303      if (id === sessionId) continue
304      const c = await $.store.get('cost:' + id)
305      if (c && c.cum) sum += c.cum
306    }
307  } catch {}
308  return sum + sessionCost
309}
310async function calibrate($) {
311  let limits = []
312  try {
313    limits = (await $.session.usage()).rateLimits || []
314  } catch {}
315  if (!limits.length) return
316  const cost = await totalCost($)
317  let samples = {}
318  try {
319    samples = (await $.store.get('gauge-samples')) || {}
320  } catch {}
321  let changed = false
322  for (const kind of ['five_hour', 'seven_day']) {
323    const l = limits.find(x => x.kind === kind)
324    if (!l || l.percentUsed == null) continue
325    const base = calib[kind]
326    if (!base || base.resetsAt !== l.resetsAt || l.percentUsed < base.pct) {
327      calib[kind] = { resetsAt: l.resetsAt, pct: l.percentUsed, cost } // 새 창이거나 처음: 기준점만
328      continue
329    }
330    const dp = l.percentUsed - base.pct
331    const dc = cost - base.cost
332    if (dp >= 2) {
333      // 2%p 이상 올랐을 때 한 표본 (게이지가 정수로 움직여 작은 변화는 오차가 크다). 이 컴퓨터 세션이 쓴 게 거의 없으면 버린다
334      if (dc > 0.05) {
335        const list = samples[kind] || []
336        list.push(dc / dp)
337        samples[kind] = list.slice(-30)
338        changed = true
339      }
340      calib[kind] = { resetsAt: l.resetsAt, pct: l.percentUsed, cost }
341    }
342  }
343  if (changed) {
344    try {
345      await $.store.set('gauge-samples', samples)
346    } catch {}
347  }
348}
349// 1% 당 약 $X: 표본 5개 이상일 때 80번째 백분위 (그 전엔 보정 중). 같은 계정을 다른 사람·다른 PC 와 같이 쓰면 그쪽 사용도 게이지를
350// 올려서 Δ$/Δ% 는 늘 작은 쪽으로만 치우친다 (분자엔 이 컴퓨터 비용만 들어가니까). 그래서 가운데 값이 아니라 높은 쪽을 쓴다
351async function dollarsPerPct($) {
352  let samples = {}
353  try {
354    samples = (await $.store.get('gauge-samples')) || {}
355  } catch {}
356  const med = list => {
357    if (!list || list.length < 5) return null
358    const a = [...list].sort((x, y) => x - y)
359    return a[Math.min(a.length - 1, Math.floor(a.length * 0.8))]
360  }
361  return { five_hour: med(samples.five_hour), seven_day: med(samples.seven_day), n5: (samples.five_hour || []).length, n7: (samples.seven_day || []).length }
362}
363// 카드에 보이는 절약: 오늘 내 사용량 대비 % (게이지 % 는 /auto-model 상세에 참고용)
364async function publishSaving($) {
365  let all = {}
366  try {
367    all = (await $.store.get('savings')) || {}
368  } catch {}
369  const now = await $.clock.now()
370  const todayRec = all[dayKey(now)] || {}
371  const today = todayRec.net || 0
372  const spent = (todayRec.spent || 0) + pendingSpent
373  let weekStart = now - 6 * 86400000
374  try {
375    const l = ((await $.session.usage()).rateLimits || []).find(x => x.kind === 'seven_day')
376    if (l && l.resetsAt) weekStart = Date.parse(l.resetsAt) - 7 * 86400000 // 이번 주간 창이 시작된 때
377  } catch {}
378  const week = Object.entries(all).filter(([k]) => k >= dayKey(weekStart)).reduce((a, [, d]) => a + d.net, 0)
379  const per = await dollarsPerPct($)
380  const value = {
381    usdToday: Math.max(0, today),
382    usdWeek: Math.max(0, week),
383    fivePct: per.five_hour ? Math.max(0, today) / per.five_hour : null,
384    weekPct: per.seven_day ? Math.max(0, week) / per.seven_day : null,
385    calibrated: !!(per.five_hour && per.seven_day),
386    // 오늘 이 PC 합계 대비 아낀 비율 (상세용)
387    myPct: myShare(spent, today),
388    // 카드: 이 대화에서 아낀 비율과 금액. 이 대화의 실제 비용이 $1 도 안 되면 비율은 아직(null)
389    sessionUsd: Math.max(0, sessionNet),
390    sessionPct: myShare(sessionCost, sessionNet),
391  }
392  try {
393    await $.state.set(SAVING, value)
394  } catch {}
395}
396
397async function logCompaction($, c) {
398  try {
399    const log = (await $.store.get('compactions')) || []
400    log.push({ at: await $.clock.now(), trigger: c.trigger, mode: c.mode || null, model: c.model || null, before: c.before ?? null, after: c.after ?? null, usage: c.usage ?? null })
401    await $.store.set('compactions', log.slice(-20))
402  } catch {}
403}
404
405// ---- 압축 요약: 쉬고 와서(cold) 우리 [압축] 버튼일 때는 Sonnet, 작업 중(warm)은 원래 모델 ----
406// 시험(대화 2개, 25만·50만 토큰): Sonnet 요약은 Opus 와 사실 보존이 같고 절반 값. 다만 승인·금지 범위를 일반화해 틀린 일이
407// 있어서, 사용자 메시지를 원문으로 붙이면 사라졌다. 그 섹션은 모델에 맡기지 않고 여기서 대화에서 그대로 꺼내 붙인다.
408const COLD_SUMMARY = `아래는 Claude Code 와 사용자의 긴 대화 기록이에요. 이 대화가 압축되어, 이후에는 이 요약만 보고 같은 일을 이어 가야 해요.
409아래 항목으로 자세히 요약하세요. 사실만, 지어내지 말고, 경로·명령·숫자·버전은 정확히.
4101. 사용자의 목표와 요청 (시간 순)
4112. 핵심 기술 개념과 결정 사항 (왜 그렇게 정했는지 포함)
4123. 파일과 코드 (경로, 바뀐 내용)
4134. 오류와 고친 방법
4145. 사용자가 준 승인·허락의 범위와 금지 사항 (예외는 예외로, 일반 규칙과 구분해서)
4156. 사용자의 선호와 작업 방식
4167. 남은 작업
4178. 지금 하던 일과 바로 다음 단계
418(사용자 메시지 원문 목록은 따로 붙으니 쓰지 마세요.)
419
420=== 대화 기록 시작 ===
421`
422const SUMMARY_HEAD = '이 대화는 앞부분이 압축되어 이어지고 있어요. 아래는 앞부분의 요약과, 사용자가 보낸 메시지의 원문이에요.\n\n'
423
424const clip = (text, keep = 200) => (text.length > keep + 100 ? text.slice(0, keep) + `[…${text.length - keep}자 생략]` : text)
425// 사용자가 직접 보낸 메시지만 (시스템 알림·명령 기록·도구 결과·다른 세션의 메시지·이전 압축 요약은 빼고), 짧은 건 원문 그대로,
426// 긴 붙여넣기는 앞 200자만. 사용자가 '!' 로 직접 실행한 명령은 '(! 실행)' 으로 앞부분만 (배포 같은 승인의 근거라서)
427const NOT_TYPED = /^(<(system-reminder|task-notification|local-command|command-|cross-session-message|agent-message|bash-stdout|bash-stderr)|Another Claude session sent a message|This session is being continued from a previous conversation|\[Request interrupted)/
428export function userMessagesSection(messages) {
429  const lines = []
430  for (const m of messages || []) {
431    if (m.role !== 'user' || m.agentId) continue
432    let t = String(m.text || '').replace(/\u001b?\[?<\d+;\d+;\d+[mM]/g, '').trim() // (터미널 마우스 입력 찌꺼기)
433    if (!t || NOT_TYPED.test(t)) continue
434    t = t.replace(/\[Image: source: [^\]]*\]/g, '').trim()
435    if (!t) continue
436    const bash = /^<bash-input>([\s\S]*?)(<\/bash-input>|$)/.exec(t)
437    const line = bash ? '(! 실행) ' + clip(bash[1].trim(), 120) : clip(t)
438    lines.push(`${lines.length + 1}. ${line.replace(/\n+/g, ' ⏎ ')}`)
439  }
440  return '## 모든 사용자 메시지 (원문, 자동 추출. 긴 붙여넣기는 앞부분만)\n' + (lines.join('\n') || '(없음)')
441}
442function transcriptText(messages) {
443  const out = []
444  for (const m of messages || []) {
445    if (m.agentId) continue
446    if (m.text) out.push(`[${m.role}] ${m.text}`)
447    for (const t of m.toolUses || []) out.push(`[${m.role} 도구 ${t.tool}] ${JSON.stringify(t.input || {}).slice(0, 800)}`)
448    for (const t of m.toolResults || []) out.push(`[도구 결과] ${JSON.stringify(t).slice(0, 800)}`)
449  }
450  return out.join('\n')
451}
452async function sonnetCompact($, e) {
453  const r = await $.model.complete({
454    model: 'sonnet',
455    system: '긴 대화를 이어받을 사람을 위해 정확하게 요약하는 도우미예요. 지시를 그대로 따르세요.',
456    prompt: COLD_SUMMARY + transcriptText(e.messages) + '\n=== 대화 기록 끝 ===',
457    maxTokens: 16000,
458    timeoutMs: 15 * 60000,
459  })
460  if (!r || !r.isAnswered || !String(r.text || '').trim()) return null
461  const text = SUMMARY_HEAD + r.text.trim() + '\n\n' + userMessagesSection(e.messages)
462  // 크기는 실제로 잰 값으로: 압축 전 = Sonnet 이 실제로 읽은 양, 압축 뒤 = 요약 글 길이로 어림 (기억해 둔 last.tokens 는
463  // 그새 /compact 로 줄었을 수 있어서 쓰지 않는다)
464  const u = r.usage || {}
465  const read = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
466  return { messages: [{ role: 'user', text, toolUses: [] }], tokensBefore: read || undefined, tokensAfter: Math.ceil(text.length / 2), usage: r.usage }
467}
468
469// ---- 2단계: 모델 고르기 ----
470
471const routing = () => settings.enabled && settings.route === 'auto' && !stopped
472const modeOf = () => (!settings.enabled || settings.route !== 'auto' ? 'off' : stopped ? 'stopped' : settings.pin ? 'pin' : 'auto')
473
474async function publish($, base, model, reason) {
475  const next = { mode: modeOf(), base, model, reason }
476  if (route && route.mode === next.mode && route.base === base && route.model === model && route.reason === reason) return
477  route = next
478  await setRoute($, next)
479}
480
481async function remember($, entry) {
482  decisions.push(entry)
483  decisions = decisions.slice(-100)
484}
485async function flushLog($) {
486  try {
487    await $.store.set('decisions', decisions)
488  } catch {}
489}
490
491// 이번 턴의 모델: 사람이 고른 세션 모델(base)을 기준으로, 내릴지 / 그대로 둘지 / 되돌릴지
492async function decide($, e) {
493  const now = await $.clock.now()
494  const base = e.model
495  if (sessionBase && familyOf(base) !== familyOf(sessionBase) && familyOf(base) !== familyOf(current) && !stopped && settings.route === 'auto') {
496    stopped = true // /model 로 직접 바꿨다: 이 세션은 사람 뜻대로
497    await remember($, { at: now, turnId: e.turnId, from: current, to: base, reason: '/model 로 직접 바꿈 → 이 세션 자동 중지' })
498  }
499  sessionBase = base
500  const p = pending
501  pending = null
502  const ctx = last ? last.tokens : 0
503  const cur = current || base
504  const warm = model => warmAt[familyOf(model)] != null && now - warmAt[familyOf(model)] < settings.ttlMinutes * 60000
505  const entry = { at: now, turnId: e.turnId, from: cur, ctx, baseWarm: warm(base), prompt: p ? promptHead(p.text) : undefined }
506  const pick = async (to, reason, extra = {}) => {
507    Object.assign(entry, { to, reason }, extra)
508    await remember($, entry)
509    await publish($, base, to, reason)
510    return to
511  }
512
513  if (!settings.enabled || settings.route !== 'auto') return pick(base, '자동 꺼짐')
514  if (stopped) return pick(base, '/model 로 고른 모델')
515  if (settings.pin) return pick(MODELS[settings.pin] || base, settings.pin + ' 고정')
516  if (p && p.skip) return pick(base, '~ 로 건너뜀')
517
518  const cheap = familyOf(cur) !== familyOf(base)
519  // 헤매면 원래 모델로: 결과를 거부하는 말, 또는 도구 오류가 거듭됨
520  if (cheap && ((p && isRejection(p.text)) || errors >= 2)) {
521    const why = p && isRejection(p.text) ? '결과 거부' : `도구 오류 ${errors}번`
522    cooldown = 3
523    errors = 0
524    return pick(base, `${why} → 원래 모델로`, { rule: 'revert' })
525  }
526  if (!p) return pick(cur, '새 입력 없음 → 그대로')
527  // 이 세션에서 아직 모델 응답을 본 적이 없다 (방금 시작했거나 /reload-plugins 직후): 대화 크기도 캐시도 모른다.
528  // 모르면 그대로 둔다 (크기를 0 으로 치고 내렸다가 수십만 토큰을 새로 쓴 일이 있었다)
529  let rule = ruleOf(p.text, lastAnswer)
530  // 직전 판단이 shadow(켰다면 내렸을 것)였으면, 다음 턴이 무슨 일이었는지 붙여 둔다 (켰다면 손익 계산용)
531  const open = [...decisions].reverse().find(d => d.shadow && !d.next)
532  if (open && open.turnId !== e.turnId) open.next = { rule, turnId: e.turnId }
533  if (rule === 'heavy' || rule === 'approve') {
534    const what = rule === 'approve' ? '승인·진행 지시' : '무거운 일'
535    return pick(base, cheap ? what + ' → 원래 모델로' : what, { rule })
536  }
537  if (!last) return pick(base, '대화 크기·캐시를 아직 몰라 그대로', { rule })
538  if (rule === 'ack') return pick(cur, '맞장구만 → 그대로', { rule })
539  if (cheap) return pick(cur, rule === 'light' ? '가벼움 → 지금 모델 유지' : '애매함 → 그대로', { rule })
540  if (cooldown > 0) {
541    cooldown--
542    return pick(base, '되돌아온 직후라 유지', { rule })
543  }
544
545  // 내릴 후보와 어림값: 그대로(base) vs 바꿈(후보). 바꾸는 쪽이 확실히(30% 넘게) 쌀 때만
546  const cand = settings.mainHaiku ? MODELS.haiku : MODELS.sonnet
547  const stay = turnCost(base, ctx, warm(base))
548  const sw = turnCost(cand, ctx, warm(cand))
549  Object.assign(entry, { cand, candWarm: warm(cand), stay, sw })
550  if (!(sw < stay * 0.7)) return pick(base, rule === 'light' ? '가볍지만 캐시가 따뜻해 그대로가 쌈' : '애매함 → 그대로', { rule })
551
552  // 애매하면 작은 모델에게 한 번 묻는다 (확실히 가볍다고 할 때만 내린다). 실패하면 그대로
553  let label = null
554  if (rule === 'unsure') {
555    try {
556      label = (await $.model.classify(p.text, ['light', 'heavy', 'unsure'])) || 'unsure'
557    } catch {
558      label = 'error'
559    }
560    if (label !== 'light') return pick(base, `애매함 (분류: ${label}) → 그대로`, { rule, label })
561    rule = 'light'
562  }
563  // 기억 확인 질문의 전환은 아직 shadow: 실제로는 그대로 두고 '켰다면'만 기록한다. 쉬고 와서 바로 일을 이어 가면
564  // 다음 턴에 원래 모델이 다시 써야 해서 왕복 손해라, 손익이 플러스로 확인되면 /auto-model light on 으로 켠다
565  if (!settings.lightSwitch) return pick(base, `가벼움 · 켰다면 ${prettyName(cand)} (shadow)`, { rule, label, shadow: true })
566  return pick(cand, `가벼움 · 바꾸는 쪽이 쌈 (${usd(sw)} < ${usd(stay)})`, { rule, label })
567}
568
569// ---- 3단계: 일꾼 ----
570// 판단이 필요 없는 일(쓰기는 Sonnet, 찾기·읽기는 Haiku)은 Opus 를 부르지 않고 일꾼 서브에이전트가 맡는다. 일꾼은 대화 전체가
571// 아니라 요청과 최근 몇 턴만 받는다. 일꾼의 답이 그 턴의 답으로 대화 끝에 붙으므로 Opus 의 캐시(대화 앞부분)는 그대로다.
572// (시제품 실측: 일꾼 턴 뒤 Opus 는 74만 토큰을 캐시로 그대로 읽고 새로 쓴 건 6~7천 토큰)
573async function recentContext($) {
574  try {
575    const msgs = await $.session.messages()
576    const tail = msgs.filter(m => !m.agentId && m.text).slice(-6)
577    return tail.map(m => (m.role === 'user' ? '사용자: ' : 'Claude: ') + m.text.slice(0, 1500)).join('\n\n')
578  } catch {
579    return ''
580  }
581}
582
583// 일꾼을 띄우고 끝날 때까지 기다린다: 답 텍스트, 또는 null (못 띄움 / 시간 초과 / 중단 / 맥락 부족)
584async function runWorker($, work) {
585  const context = await recentContext($)
586  const prompt =
587    '아래 요청을 처리해 주세요. 당신은 긴 대화의 일부만 넘겨받은 작업자예요. 요청을 그대로 처리하고, 결과를 사용자에게 보여 줄 짧은 보고로 끝내세요. ' +
588    '대화의 다른 부분이 꼭 필요해 보이면 추측하지 말고 첫 줄을 "맥락이 부족해요:" 로 시작해 답하세요.\n\n' +
589    (context ? '## 최근 대화 (참고)\n' + context + '\n\n' : '') +
590    '## 요청\n' + work.text
591  const sp = await $.agent.spawn({ prompt, description: 'auto-model 일꾼', subagentType: 'general-purpose', model: work.model })
592  if (!sp || !sp.agentId) return { failed: '일꾼을 못 띄움' }
593  workerIds.add(sp.agentId)
594  const done = await Promise.race([new Promise(resolve => workerWaiters.set(sp.agentId, resolve)), $.clock.sleep(WORKER_WAIT_MS).then(() => null)])
595  workerWaiters.delete(sp.agentId)
596  if (done && done.usage) await addCost($, done.usage.model || sp.model, done.usage) // (우리 일꾼의 단계는 우리 훅을 건너뛰어서 여기서 센다)
597  if (!done) return { failed: '시간 초과', agentId: sp.agentId }
598  if (done.isAborted) return { failed: '중단됨', agentId: sp.agentId }
599  const answer = String(done.answer || '')
600  if (!answer.trim() || /^\s*맥락이 부족해요/.test(answer)) return { failed: '맥락 부족', answer, agentId: sp.agentId, usage: done.usage }
601  return { answer, agentId: sp.agentId, usage: done.usage, model: (done.usage && done.usage.model) || sp.model }
602}
603
604async function statusText($) {
605  const lines = []
606  const share = myShare(sessionCost, sessionNet)
607  if (sessionNet > 0.0005 || sessionCost > 0)
608    lines.push(`💰 이 대화: ` + (share != null ? `약 ${Math.round(share)}% 아낌 (약 ${usd(sessionNet)} 절약 · 실제 ${usd(sessionCost)} 씀)` : `약 ${usd(Math.max(0, sessionNet))} 절약 (실제 ${usd(sessionCost)} 씀 · 비율은 $1 넘게 쓴 뒤부터)`))
609  const sv = await savingsText($)
610  if (sv) lines.push(sv)
611  const per = await dollarsPerPct($)
612  lines.push(per.five_hour && per.seven_day
613    ? `게이지 환산: 5시간 1% ≈ $${per.five_hour.toFixed(2)} · 주간 1% ≈ $${per.seven_day.toFixed(2)} (이 컴퓨터 세션들의 비용과 게이지 변화로 어림, 표본 ${per.n5}·${per.n7}개)`
614    : `게이지 환산 보정 중 (표본 5시간 ${per.n5}/5 · 주간 ${per.n7}/5)`)
615  lines.push('(같은 계정을 다른 사람·다른 PC 와 같이 쓰면 게이지 환산은 어림값이에요. 이 PC 합계 % 는 이 컴퓨터 숫자만 써서 정확해요)')
616  const mode = modeOf()
617  lines.push('auto-model ' + (settings.enabled ? '켜짐' : '꺼짐') + ' · 자동 전환: ' + { auto: '켜짐', off: '꺼짐', stopped: '/model 로 직접 골라서 이 세션은 멈춤', pin: (settings.pin || '') + ' 고정' }[mode] + (settings.mainHaiku ? ' · 메인 Haiku 허용' : ''))
618  if (route) lines.push(`지금: ${prettyName(route.model)}` + (familyOf(route.model) !== familyOf(route.base) ? ` (원래 ${prettyName(route.base)})` : '') + ` · ${route.reason}`)
619  lines.push(`캐시 식는 시간 ${settings.ttlMinutes}분 · 압축 제안 ${tokensText(settings.minTokens)} 이상`)
620  if (last) {
621    const idle = Math.floor(((await $.clock.now()) - last.at) / 60000)
622    lines.push(`마지막 호출 ${idleText(idle)} 전 (${prettyName(last.model)}) · 대화 ${tokensText(last.tokens)} · 다시 쓰면 약 ${usd(rewriteUsd(last.model, last.tokens))} (API 환산)`)
623  } else lines.push('이 세션에서 아직 모델 호출이 없어요')
624  let log = []
625  try {
626    log = (await $.store.get('compactions')) || []
627  } catch {}
628  if (log.length) {
629    lines.push('최근 압축 (압축 요청이 실제로 쓴 토큰):')
630    for (const c of log.slice(-5)) {
631      const u = c.usage || {}
632      lines.push(`  ${timeText(c.at)} · ${c.mode === 'cold' ? '쉬고 와서' : c.mode === 'warm' ? '작업 중' : c.trigger || ''}${c.model ? ' · ' + prettyName(c.model).split(' ')[0] + ' 요약' : ''} · ${c.before ? tokensText(c.before) : '?'} → ${c.after ? tokensText(c.after) : '?'} · 입력 ${u.input_tokens ?? '?'} · 캐시 읽기 ${u.cache_read_input_tokens ?? '?'} · 캐시 쓰기 ${u.cache_creation_input_tokens ?? '?'} · 출력 ${u.output_tokens ?? '?'}`)
633    }
634  }
635  lines.push('(/auto-model log: 최근 판단 기록)')
636  return lines.join('\n')
637}
638
639async function logText($) {
640  let saved = []
641  try {
642    saved = (await $.store.get('decisions')) || []
643  } catch {}
644  const all = saved.length >= decisions.length ? saved : decisions
645  let subs = []
646  try {
647    subs = (await $.store.get('subagents')) || []
648  } catch {}
649  const lines = []
650  // shadow 요약: 기억 확인 질문에서 전환을 켰다면 어땠을지 (다음 턴이 무거우면 원래 모델이 대화를 다시 쓴다)
651  const shadows = all.filter(d => d.shadow && d.next && d.cand)
652  if (shadows.length) {
653    let pnl = 0
654    let heavyNext = 0
655    for (const d of shadows) {
656      const back = d.next.rule === 'heavy' || d.next.rule === 'approve'
657      if (back) heavyNext++
658      const actual = d.stay + turnCost(d.to, d.ctx, true)
659      const would = d.sw + (back ? turnCost(d.to, d.ctx, false) : turnCost(d.cand, d.ctx, true))
660      pnl += actual - would
661    }
662    lines.push(`기억 확인 질문 전환 (shadow): 후보 ${shadows.length}번 중 다음 턴이 무거운 일·승인 ${heavyNext}번 · 켰다면 예상 손익 ${pnl >= 0 ? '+' : '−'}${usd(Math.abs(pnl))} (API 환산, +면 아꼈을 것)` + (settings.lightSwitch ? ' · 지금 켜짐' : ' · 지금 꺼짐 (/auto-model light on)'))
663  }
664  // 일꾼 shadow 요약: 일꾼 후보였던 턴을 실제로 원래 모델이 처리한 값 vs 일꾼이었다면의 어림값
665  const ws = all.filter(d => d.workerShadow && d.usage)
666  if (ws.length) {
667    const actual = ws.reduce((a, d) => a + usageCost(d.to, d.usage), 0)
668    const would = ws.reduce((a, d) => a + d.workerShadow.est, 0)
669    const counts = ws.reduce((a, d) => ((a[d.workerShadow.task] = (a[d.workerShadow.task] || 0) + 1), a), {})
670    lines.push(`일꾼 후보 (shadow): ${ws.length}번 (쓰기 ${counts.write || 0} · 찾기·읽기 ${counts.search || 0}) · 실제 ${usd(actual)} vs 일꾼이었다면 약 ${usd(would)} (API 환산)` + (settings.worker === 'on' ? ' · 지금 켜짐' : settings.worker === 'shadow' ? ' · 지금 기록만 (/auto-model worker on 으로 켜기)' : ' · 지금 꺼짐'))
671  }
672  // 놓친 일꾼 후보: 원래 모델이 처리했지만 실제 행동(도구 1~3번, 단순 쓰기·찾기, 짧은 출력)으로 보면 일꾼으로 충분했던 턴
673  const handled = all.filter(d => !d.worker && d.usage)
674  const missed = handled.filter(d => d.missedWorker)
675  if (handled.length) {
676    const saved = missed.reduce((a, d) => a + Math.max(0, usageCost(d.to, d.usage) - d.missedWorker.est), 0)
677    const mc = missed.reduce((a, d) => ((a[d.missedWorker.task] = (a[d.missedWorker.task] || 0) + 1), a), {})
678    const ex = missed.filter(d => d.prompt).slice(-2).map(d => `'${d.prompt.slice(0, 30)}'`).join(', ')
679    lines.push(`원래 모델 처리 ${handled.length}번 중 놓친 일꾼 후보 ${missed.length}번 (쓰기 ${mc.write || 0} · 찾기 ${mc.search || 0}) · 일꾼이었다면 약 ${usd(saved)} 절약` + (ex ? ` · 예: ${ex}` : ''))
680  }
681  const wr = all.filter(d => d.worker)
682  if (wr.length) lines.push(`일꾼이 실제로 맡은 턴: ${wr.length}번 · 일꾼 비용 합 약 ${usd(wr.reduce((a, d) => a + (d.usage ? usageCost(d.to, d.usage) : d.est || 0), 0))}`)
683  lines.push('최근 판단 (시각 · 이전→이번 · 이유 · 대화 · 캐시 · 이번 턴 사용량):')
684  for (const d of all.slice(-15)) {
685    const u = d.usage
686    lines.push(
687      `  ${d.shadow ? '[shadow] ' : ''}${timeText(d.at)} · ${prettyName(d.from)}→${prettyName(d.to)} · ${d.reason}` +
688        (d.shadow && d.next ? ` · 다음 턴: ${d.next.rule}` : '') +
689        (d.prompt ? ` · "${d.prompt.slice(0, 40)}"` : '') +
690        (d.actualUsd != null ? ` · [실제 약 ${usd(d.actualUsd)} / 원래 모델이었다면 약 ${usd(d.opusWouldUsd)} (대화 ${tokensText(d.ctx || 0)}, 캐시 ${d.baseWarm ? '따뜻' : '식음'}, ${d.steps}단계 가정)]` : '') +
691        (d.missedWorker ? ` · [놓친 일꾼 후보: ${d.missedWorker.task === 'write' ? '쓰기' : '찾기'} · ${d.missedWorker.why}]` : '') +
692        (d.workerShadow ? ` · [일꾼 후보: ${d.workerShadow.task === 'write' ? '쓰기' : '찾기·읽기'}, ${prettyName(d.workerShadow.model).split(' ')[0]} 였다면 약 ${usd(d.workerShadow.est)}]` : '') +
693        (d.ctx ? ` · ${tokensText(d.ctx)}` : '') +
694        (d.baseWarm != null ? ` · 원래 모델 캐시 ${d.baseWarm ? '따뜻' : '식음'}` : '') +
695        (u ? ` · 입력 ${u.input_tokens} 캐시읽기 ${u.cache_read_input_tokens} 캐시쓰기 ${u.cache_creation_input_tokens} 출력 ${u.output_tokens}` : ''),
696    )
697  }
698  if (subs.length) {
699    lines.push('최근 서브에이전트 (종류 · 요청한 모델 → 실제 모델):')
700    for (const s of subs.slice(-8)) lines.push(`  ${timeText(s.at)} · ${s.type} · ${s.requested || '(지정 없음)'} → ${s.resolved || '?'}${s.routed ? ' (검색류라 haiku 로)' : ''}`)
701  }
702  return lines.join('\n')
703}
704
705export function register(on) {
706  on('session.start', async ($, e, next) => {
707    const r = await next(e)
708    try {
709      settings = { ...DEFAULTS, ...((await $.store.get('settings')) || {}) }
710    } catch {}
711    await restoreMemory($)
712    await restoreCost($)
713    await $.command.register({ name: 'auto-model', description: '캐시를 아끼며 모델을 골라요: 쉰 뒤 압축 제안, 가벼운 요청 자동 전환 (on · off · auto · manual · pin · log)' })
714    $.clock.every(30000, () => void check($))
715    $.clock.every(60000, () => void (async () => {
716      await flushCost($)
717      await calibrate($)
718      await publishSaving($)
719    })())
720    return r
721  })
722
723  // 메인 대화의 모델 호출: 첫 단계에서 모델을 정하고 그 턴 동안 유지, 응답마다 시각·크기·답한 모델을 적는다
724  // (서브에이전트의 호출은 메인 캐시와 무관해서 건드리지 않는다)
725  on('turn.step', async function* ($, e, next) {
726    if (e.agentId) {
727      const ra = yield* next(e)
728      if (ra && ra.usage) await addCost($, ra.usage.model, ra.usage) // 서브에이전트도 같은 게이지를 쓴다
729      return ra
730    }
731    liveTurnId = e.turnId
732    // 일꾼에게 맡길 턴: 첫 단계에서 Opus 대신 일꾼을 띄우고, 일꾼의 답을 이 턴의 답으로 낸다
733    const work = e.index === 0 ? pendingWork : null
734    if (work) pendingWork = null
735    const workerOn = work && settings.enabled && (work.manual || (settings.worker === 'on' && settings.route === 'auto' && !stopped && !settings.pin))
736    if (workerOn) {
737      const started = await $.clock.now()
738      let res
739      try {
740        res = await runWorker($, work)
741      } catch (err) {
742        res = { failed: '일꾼 오류: ' + String((err && err.message) || err).slice(0, 80) }
743      }
744      const entry = { at: started, turnId: e.turnId, from: current || e.model, to: res.model || work.model, reason: '', task: work.task, worker: true, manual: work.manual, ctx: last ? last.tokens : 0, usage: res.usage || null, est: workerEstimate(res.model || work.model), prompt: promptHead(work.text) }
745      if (res.failed && res.failed !== '맥락 부족') {
746        entry.reason = `일꾼 ${res.failed} → 원래 모델로`
747        entry.to = e.model
748        await remember($, entry)
749        if (res.usage) await addSaving($, 'loss', usageCost(res.usage.model || work.model, res.usage)) // 헛쓴 일꾼 값
750        return yield* next(e) // 못 하면 이 턴은 Opus 가
751      }
752      const name = prettyName(res.model || work.model).split(' ')[0]
753      const secs = Math.round(((await $.clock.now()) - started) / 1000)
754      // 절약: 실제(일꾼 사용량 + 원래 모델이 붙은 부분을 새로 쓰는 값) vs 원래 모델이 했다면 (대화 크기·캐시 상태·단계 수로 어림)
755      const wModel = (res.usage && res.usage.model) || res.model || work.model
756      const ctxNow = last ? last.tokens : 0
757      const baseWarm = warmAt[familyOf(e.model)] != null && started - warmAt[familyOf(e.model)] < settings.ttlMinutes * 60000
758      const actual = (res.usage ? usageCost(wModel, res.usage) : workerEstimate(wModel)) + appendCost(e.model, (work.text || '').length + (res.answer || '').length)
759      const would = opusWouldWorker(e.model, ctxNow, baseWarm, work.task)
760      Object.assign(entry, { actualUsd: actual, opusWouldUsd: would, baseWarm, steps: work.task === 'write' ? 3 : 2 })
761      const baseName = prettyName(e.model).split(' ')[0]
762      const savingLine = res.failed ? '' : `\n\n💰 ${name} 처리 · 실제 약 ${usd(actual)} · ${baseName}였다면 약 ${usd(would)} → 약 ${usd(Math.max(0, would - actual))} 절약`
763      if (res.failed) await addSaving($, 'loss', actual)
764      else await addSaving($, 'worker', would - actual)
765      const text = (res.failed ? `🐕 ${name} 일꾼이 맥락이 부족하다고 했어요. 다음 요청은 원래 모델이 이어받아요.\n\n` : `🐕 ${name} 가 처리했어요 (일꾼, ${secs}초)\n\n`) + (res.answer || '') + savingLine
766      if (res.failed) cooldown = Math.max(cooldown, 1)
767      entry.reason = res.failed ? '일꾼: 맥락 부족' : `일꾼 (${work.task === 'write' ? '쓰기' : '찾기·읽기'}${work.manual ? ', 손으로' : ''})`
768      await remember($, entry)
769      lastAnswer = text.slice(-200)
770      // (메인 턴의 사용량은 비워 둔다: 일꾼의 작은 대화 크기가 메인 대화 크기로 잡히지 않게. 일꾼 비용은 서브에이전트 쪽에 잡힌다)
771      yield { kind: 'text', index: 0, text }
772      yield { kind: 'stop', stopReason: 'end_turn', usage: null }
773      return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
774    }
775    let model = turnModel.get(e.turnId)
776    if (model === undefined) {
777      try {
778        model = await decide($, e)
779      } catch {
780        model = e.model // 판단이 실패하면 그대로 (fail open)
781      }
782      turnModel.set(e.turnId, model)
783      if (turnModel.size > 50) turnModel.delete(turnModel.keys().next().value)
784      // 일꾼 shadow: 실제로는 이 모델이 처리하고, '일꾼이었다면'만 이 턴의 판단 기록에 붙인다 (턴이 끝나면 실제 사용량과 비교)
785      const d = decisions.length && decisions[decisions.length - 1]
786      if (work && d && d.turnId === e.turnId) {
787        const wm = work.model === 'haiku' ? MODELS.haiku : MODELS.sonnet
788        d.workerShadow = { task: work.task, model: wm, est: workerEstimate(wm) }
789      }
790    }
791    const r = yield* next(model && model !== e.model ? { ...e, model } : e)
792    if (r && r.usage) {
793      await addCost($, r.usage.model, r.usage)
794      const now = await $.clock.now()
795      last = { at: now, tokens: contextOf(r.usage), model: r.usage.model }
796      warmAt[familyOf(r.usage.model)] = now
797      current = r.usage.model
798      if (r.answer) lastAnswer = String(r.answer).slice(-200)
799      await clearHint($, 'cold')
800      const d = decisions.length && decisions[decisions.length - 1]
801      if (d && d.turnId === e.turnId) {
802        const u = d.usage || { input_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 0 }
803        for (const k of Object.keys(u)) u[k] += r.usage[k] || 0
804        d.usage = u
805      }
806      await saveMemory($)
807    }
808    return r
809  })
810
811  // /resume · fork 로 다른 대화를 이어받으면, 엔진이 그 대화의 마지막 응답 뒤 지난 시간과 크기를 알려 준다:
812  // 그걸로 캐시가 따뜻한지 추정한다 (없으면 모르는 채로 = 그대로)
813  on('classic.SessionStart', async ($, e, next) => {
814    const r = await next(e)
815    try {
816      if ((e.source === 'resume' || e.source === 'fork') && typeof e.seconds_since_last_response === 'number' && e.context_tokens) {
817        sessionId = await $.session.id()
818        const at = (await $.clock.now()) - e.seconds_since_last_response * 1000
819        const model = e.model || current || sessionBase || MODELS.opus
820        last = { at, tokens: e.context_tokens, model }
821        for (const k of Object.keys(warmAt)) delete warmAt[k]
822        warmAt[familyOf(model)] = at
823        current = model
824        await saveMemory($)
825      }
826    } catch {}
827    return r
828  }).catch(($, e, next) => next(e))
829
830  // 입력: 제안은 할 일을 다 했다 (그 요청이 어차피 다시 쓴다). 2단계 판단을 위해 문장을 적어 둔다.
831  // (끼어들기만 하고 막지 않는다: 실패해도 입력은 그대로 간다)
832  on('prompt.submit', async ($, e, next) => {
833    const text = typeof e.text === 'string' ? e.text : ''
834    // 우리가 띄운 일꾼의 결과가 메인 대화에 또 배달되면 넣지 않는다 (그대로 두면 그 배달이 새 턴이 되어 Opus 가 대화 전체를 또 읽는다)
835    if (e.origin && workerIds.size) {
836      const id = [...workerIds].find(x => text.includes(x))
837      if (id) {
838        workerIds.delete(id)
839        return { drop: '🐕 일꾼의 결과는 이미 그 턴의 답으로 붙였어요' }
840      }
841    }
842    await clearHint($)
843    if (text.startsWith('/')) return next(e) // 명령은 판단 거리가 아니다
844    pendingWork = null
845    // '@@ 요청' 은 Sonnet 일꾼, '@@h 요청' 은 Haiku 일꾼에게 바로 (손으로 고르기)
846    const manual = /^\s*@@(h?)\s+([\s\S]+)/.exec(text)
847    if (manual) {
848      pendingWork = { task: manual[1] ? 'search' : 'write', model: manual[1] ? 'haiku' : 'sonnet', text: manual[2], manual: true }
849      pending = { text: manual[2], skip: false }
850      return next({ ...e, text: manual[2] })
851    }
852    if (!e.origin) {
853      const task = taskOf(text, lastAnswer)
854      if (task === 'write' || task === 'search') pendingWork = { task, model: task === 'write' ? 'sonnet' : 'haiku', text, manual: false }
855    }
856    // '~ 요청' 은 판단을 건너뛴다. 사람이 친 것만: 다른 세션의 메시지(<cross-session-message …>)나 붙여넣은 HTML 처럼 '<' 로
857    // 시작하는 글은 건드리지 않는다 (예전엔 '<' 도 건너뛰기로 봐서 맨 앞 글자를 지웠다)
858    if (!e.origin && text.startsWith('~')) {
859      pending = { text: text.slice(1).trimStart(), skip: true }
860      return next({ ...e, text: pending.text })
861    }
862    pending = { text, skip: false }
863    return next(e)
864  }).catch(($, e, next) => next(e))
865
866  // 싼 모델 턴의 도구 오류를 센다 (거듭되면 다음 턴에 원래 모델로)
867  on('tool.call', async ($, e, next) => {
868    const r = await next(e)
869    if (!e.agentId && liveTurnId) {
870      const list = turnTools.get(liveTurnId) || []
871      list.push({ tool: String(e.tool || ''), isError: !!(r && r.isError) })
872      turnTools.set(liveTurnId, list)
873      if (turnTools.size > 30) turnTools.delete(turnTools.keys().next().value)
874    }
875    if (!e.agentId && route && familyOf(route.model) !== familyOf(route.base)) {
876      if (r && r.isError) errors++
877    } else if (!e.agentId) errors = 0
878    return r
879  }).catch(($, e, next) => next(e))
880
881  on('turn.complete', async ($, e, next) => {
882    if (e.agentId && workerWaiters.has(e.agentId)) workerWaiters.get(e.agentId)(e)
883    // 사후 판정: 원래 모델이 처리한 메인 턴이 사실 일꾼으로 충분했는지, 그 턴의 실제 도구 사용과 출력으로 본다
884    if (!e.agentId && e.turnId) {
885      const d = [...decisions].reverse().find(x => x.turnId === e.turnId)
886      if (d && d.workerShadow && d.usage && !d.potentialCounted) {
887        d.potentialCounted = true
888        await addSaving($, 'potential', Math.max(0, usageCost(d.to, d.usage) - d.workerShadow.est))
889      }
890      if (d && !d.worker && d.usage) {
891        const miss = missedWorkerOf(turnTools.get(e.turnId), d.usage)
892        if (miss) {
893          const wm = miss.task === 'write' ? MODELS.sonnet : MODELS.haiku
894          d.missedWorker = { task: miss.task, why: miss.why, model: wm, est: workerEstimate(wm) }
895        }
896      }
897      turnTools.delete(e.turnId)
898    }
899    const r = await next(e)
900    await flushLog($)
901    return r
902  }).catch(($, e, next) => next(e))
903
904  // 서브에이전트: 모델을 지정하지 않은 검색·탐색류는 Haiku 로. 지정한 모델이 있으면 그게 먼저. 모두 기록한다.
905  on('agent.spawn', async ($, e, next) => {
906    const routed = routing() && !e.model && !e.fork && isSearchy(e.subagentType, e.description)
907    const r = await next(routed ? { ...e, model: 'haiku' } : e)
908    try {
909      const subs = (await $.store.get('subagents')) || []
910      subs.push({ at: await $.clock.now(), type: e.subagentType, requested: routed ? 'haiku' : e.model || null, resolved: (r && r.model) || null, routed })
911      await $.store.set('subagents', subs.slice(-50))
912    } catch {}
913    return r
914  }).catch(($, e, next) => next(e))
915
916  // 압축이 끝나면: 대화 크기를 줄여 적고, 압축 요청이 실제로 쓴 토큰을 기록한다 (제안이 맞는지 나중에 보려고)
917  // 압축: 우리 [압축] 버튼(trigger plugin)일 때만 손을 댄다. 쉬고 와서 캐시가 식었으면 Sonnet 이 요약하고, 작업 중이면 원래 모델이
918  // 요약하되 사용자 메시지 원문을 붙인다. 사람이 친 /compact(manual)와 자동 압축(auto)은 그대로. 실패하면 원래대로 (fail open)
919  on('session.compact', async ($, e, next) => {
920    // 다른 플러그인(usage-meter 의 [압축] · /terry compact)이 시작한 압축: 종류는 미리 들은 게 있으면 그것, 없으면 지금 상태로 판단
921    // (클릭 핸들러에서 명령을 부르면 Windows 에서 매달려서, 미리 알리지 않아도 되게 했다)
922    const now0 = await $.clock.now()
923    const prepped = compactMode && now0 < compactModeUntil ? compactMode : null
924    const mode = !e.agentId && e.trigger === 'plugin' ? prepped || shownKind || (last && now0 - last.at >= settings.ttlMinutes * 60000 ? 'cold' : 'warm') : null
925    if (mode) (compactMode = null), (askHandled = true)
926    let r = null
927    let model = null
928    if (mode === 'cold' && last && (await $.clock.now()) - last.at >= settings.ttlMinutes * 60000) {
929      let why = 'Sonnet 답이 비었어요'
930      try {
931        r = await sonnetCompact($, e)
932        if (r) model = MODELS.sonnet
933      } catch (err) {
934        r = null
935        why = String((err && err.message) || err).slice(0, 80)
936      }
937      if (!r) $.ui.toast(`📦 Sonnet 요약 실패 (${why}) → 원래 모델이 압축해요`)
938    }
939    if (!r) {
940      r = await next(e)
941      if (mode && r && r.messages) r = { ...r, messages: [...r.messages, { role: 'user', text: userMessagesSection(e.messages), toolUses: [] }] }
942    }
943    if (!e.agentId && r && r.messages) {
944      // 어떤 압축이든(직접 친 /compact·자동 포함) 대화 크기를 새로 적고 세션 상태에도 남긴다: 안 그러면 /reload-plugins 뒤
945      // 압축 전 크기가 되살아나 이미 압축된 대화에 또 압축을 권했다
946      if (last) last = { ...last, tokens: r.tokensAfter || Math.min(last.tokens, 30000) }
947      if (last) dismissedFor = last.at
948      await saveMemory($)
949      await clearHint($)
950      if (mode) compactHandled = true
951      await logCompaction($, { trigger: e.trigger, mode, model, before: r.tokensBefore, after: r.tokensAfter, usage: r.usage })
952      if (r.usage) await addCost($, model || current || sessionBase || MODELS.opus, r.usage) // 압축도 실제로 쓴 값
953      if (model && r.usage) {
954        // 쉬고 와서 Sonnet 이 압축: 원래 모델이 대화 전체를 다시 써서 요약했다면 대비
955        const actual = usageCost(model, r.usage)
956        const base = current || sessionBase || MODELS.opus
957        // 원래 모델이 같은 대화를 읽었다면: Sonnet 이 실제로 읽은 양 기준 (작은 대화면 절약도 작다)
958        const read = (r.usage.input_tokens || 0) + (r.usage.cache_read_input_tokens || 0) + (r.usage.cache_creation_input_tokens || 0)
959        const would = opusWouldCompact(base, read, r.usage.output_tokens)
960        await addSaving($, 'compact', would - actual)
961        $.ui.toast(`📦 Sonnet 압축 약 ${usd(actual)} · ${prettyName(base).split(' ')[0]}였다면 약 ${usd(would)} → 약 ${usd(Math.max(0, would - actual))} 절약`)
962      }
963    }
964    return r
965  }).catch(($, e, next) => next(e))
966
967  on('command.run', { command: 'auto-model' }, async ($, e) => {
968    const [word = '', ...rest] = (e.args || '').trim().split(/\s+/)
969    const arg = word.toLowerCase()
970    const save = async () => {
971      try {
972        await $.store.set('settings', settings)
973      } catch {}
974    }
975    const refreshRoute = async () => {
976      if (route) await publish($, route.base, route.model, route.reason)
977    }
978    if (arg === 'on' || arg === 'off') {
979      settings.enabled = arg === 'on'
980      if (arg === 'on') stopped = false
981      await save()
982      if (!settings.enabled) await clearHint($)
983      else await check($)
984      await refreshRoute()
985      return { text: 'auto-model ' + (settings.enabled ? '켰어요 (압축 제안 + 자동 전환)' : '껐어요. 다음 요청부터 원래 모델 그대로예요') }
986    }
987    if (arg === 'auto' || arg === 'manual') {
988      settings.route = arg
989      if (arg === 'auto') stopped = false
990      await save()
991      await refreshRoute()
992      return { text: arg === 'auto' ? '자동 전환을 켰어요' : '자동 전환을 껐어요 (압축 제안은 그대로)' }
993    }
994    if (arg === 'pin' || arg === 'unpin') {
995      const m = (rest[0] || '').toLowerCase()
996      if (arg === 'pin' && !MODELS[m]) return { text: '/auto-model pin opus | sonnet | haiku' }
997      settings.pin = arg === 'pin' ? m : null
998      await save()
999      await refreshRoute()
1000      return { text: arg === 'pin' ? `다음 요청부터 ${m} 로 고정해요` : '고정을 풀었어요' }
1001    }
1002    if (arg === 'haiku') {
1003      settings.mainHaiku = (rest[0] || '').toLowerCase() === 'on'
1004      await save()
1005      return { text: settings.mainHaiku ? '메인 대화도 가벼우면 Haiku 까지 내려요' : '메인 대화는 Sonnet 까지만 내려요' }
1006    }
1007    if (arg === 'light') {
1008      settings.lightSwitch = (rest[0] || '').toLowerCase() === 'on'
1009      await save()
1010      return { text: settings.lightSwitch ? '기억 확인 질문도 캐시가 식었으면 Sonnet 으로 내려요' : '기억 확인 질문은 내리지 않고 "켰다면"만 기록해요 (shadow)' }
1011    }
1012    if (arg === 'worker') {
1013      const m = (rest[0] || '').toLowerCase()
1014      if (!['on', 'off', 'shadow'].includes(m)) return { text: '/auto-model worker on | shadow | off (지금: ' + settings.worker + ')' }
1015      settings.worker = m
1016      await save()
1017      return { text: { on: '판단이 필요 없는 일은 일꾼(쓰기 Sonnet, 찾기·읽기 Haiku)이 맡아요', shadow: '일꾼 후보만 기록하고 실제로는 원래 모델이 처리해요', off: '일꾼을 껐어요 (@@ 로 손으로 부르는 건 그대로)' }[m] }
1018    }
1019    if (arg === 'log') return { text: await logText($) }
1020    if (arg === 'ttl' || arg === 'min') {
1021      const n = Number(rest[0])
1022      if (!Number.isFinite(n) || n <= 0) return { text: arg === 'ttl' ? '/auto-model ttl <분> (예: 60)' : '/auto-model min <천 토큰> (예: 150)' }
1023      if (arg === 'ttl') settings.ttlMinutes = n
1024      else settings.minTokens = n * 1000
1025      await save()
1026      shownMinutes = -1
1027      await check($)
1028      return { text: arg === 'ttl' ? `캐시 식는 시간을 ${n}분으로 했어요` : `${tokensText(n * 1000)} 이상인 대화에만 제안해요` }
1029    }
1030    if (arg === 'compact') {
1031      // 직접 친 압축도 카드의 [압축] 과 같은 길로: 우리가 시작한 압축은 우리 훅이 못 가로채서(쉬고 온 뒤 Sonnet 요약이 안 됨)
1032      // usage-meter 가 있으면 그쪽이 시작한다. 지시를 붙였거나 usage-meter 가 없으면 원래 모델이
1033      // (명령 훅 안에서 다른 명령을 부르면 host 가 거절하고, 버튼에선 매달리기도 해서 명령 대신 상태로 부탁한다)
1034      if (!rest.length) {
1035        askHandled = false
1036        const now = await $.clock.now()
1037        try {
1038          await $.state.set(ASK, { id: now, at: now })
1039        } catch {}
1040        void (async () => {
1041          await $.clock.sleep(3000)
1042          if (!askHandled) await compactNow($, '') // usage-meter 가 없으면 원래 모델이
1043        })()
1044        return { text: '압축을 시작할게요 (쉬고 온 뒤면 Sonnet 이 요약, 끝나면 알려 드려요)' }
1045      }
1046      return { text: await compactNow($, rest.join(' ')) }
1047    }
1048    // 카드의 [압축] 버튼: 이어서 usage-meter 가 압축을 시작한다. 그 압축이 쉬고 온 뒤인지(Sonnet) 작업 중인지 적어 둔다
1049    if (arg === 'compact-prep') {
1050      compactMode = rest[0] === 'cold' || rest[0] === 'warm' ? rest[0] : shownKind || 'warm'
1051      compactModeUntil = (await $.clock.now()) + 60000
1052      await clearHint($)
1053      return { text: compactMode }
1054    }
1055    if (arg === 'preview') {
1056      const tokens = last ? last.tokens : 460000
1057      const model = last ? last.model : MODELS.opus
1058      previewUntil = (await $.clock.now()) + 60000
1059      shownMinutes = 72
1060      await setHint($, { id: -1, idleMinutes: 72, tokens, rewriteUsd: rewriteUsd(model, tokens), model })
1061      return { text: '제안을 1분 동안 보여 줄게요 (테리 카드 아래)' }
1062    }
1063    if (arg === 'dismiss') {
1064      if (shownKind === 'warm') {
1065        warmSnoozePct = shownPct + 5 // '나중에': 5%p 더 찰 때까지 다시 안 묻는다
1066        await clearHint($)
1067        return { text: `${warmSnoozePct}% 가 되면 다시 알려 드릴게요` }
1068      }
1069      if (last) dismissedFor = last.at
1070      await clearHint($)
1071      return { text: '이번엔 그냥 계속할게요' }
1072    }
1073    if (arg === 'warn') {
1074      const v = (rest[0] || '').toLowerCase()
1075      const n = v === 'off' ? 0 : Number(v)
1076      if (!Number.isFinite(n) || n < 0 || n > 100) return { text: '/auto-model warn <퍼센트> | off (지금: ' + (settings.warnPct || 'off') + ')' }
1077      settings.warnPct = n
1078      warmSnoozePct = 0
1079      await save()
1080      await check($)
1081      return { text: n ? `대화가 ${n}% 차면 압축을 권할게요` : '작업 중 압축 알림을 껐어요' }
1082    }
1083    return { text: await statusText($) }
1084  })
1085}
1086
hooks/route.js 145 lines
1// auto-model 2단계의 판단 재료: 요청 문장의 가벼움(로컬 규칙), 모델별 단가, 전환 비용 어림.
2// 화면이나 엔진과는 무관한 순수 함수만 둔다 (테스트하기 쉽게).
3
4// 확실히 무거운 일: 코드나 파일을 만들고 고치는 말, 코드 조각·파일 경로, 이미지·파일 첨부, 불만이나 문제 제보, 긴 지시
5const HEAVY = /(구현|만들|고쳐|고치|수정|리팩|버그|에러|오류|깨져|깨짐|이상|문제|안 ?나|안 ?보|설계|작성|추가해|바꿔|변경|빌드|테스트|배포|올려|올리|커밋|푸시|삭제|지워|분석|조사|검토|리뷰|최적화|마이그|디버그|implement|refactor|fix|build|deploy|debug|broken|```|\[(?:Image|Pasted)|@\S|[\w-]+\/[\w./-]+|\w+\.(?:js|ts|tsx|py|json|md|go|rs|java|sh|css|html)\b)/i
6// 가벼운 질문: '~였던가/뭐였지/어디였지' 처럼 새 일을 시키지 않고 기억을 묻는 짧은 질문 (물음표만 있다고 가볍지 않다)
7const LIGHT = /(어디였|뭐였|무엇이었|언제였|누구였|였던가|였더라|였지\??$|했었지\??$|더라\??$|어디까지)/
8// 맞장구·인사만 있는 말 (요청이 없다): 모델을 바꿀 이유가 아니다
9const ACK = /^(고마워요?|감사(합니다|해요)?|ㅇㅋ|ㅇㅇ|dz|dd|오케이|오키|ok|okay|굿|good|nice|ㅋ+|ㅎ+|넵|네|응|그래|좋아|좋네|오+|와+|대박|thx|thanks)[\s!.~ㅋㅎ]*$/i
10// 승인·진행 지시: 짧아도 직전 제안(설계·구현·커밋…)을 실행하라는 말이라 무거운 일이다
11const APPROVE = /(해줘|해 줘|해봐|해 봐|해주세요|진행|보내|가자|ㄱㄱ|고고|그걸로|그렇게 해|그렇게 하자|하자\b|하자$|부탁|시작해|맞춰|적용|넘겨|전달해|ㄱ$)/
12// 직전 답이 사람에게 묻거나 제안하며 끝났는지 (그 뒤의 짧은 긍정은 승인이다)
13const ASKS_BACK = /(\?|할까요|드릴까요|볼까요|갈까요|올릴까요|주세요|주시면|어떨까요|괜찮을까요|원하시면|정해 주|골라 주|알려 주)[\s.)!]*$/
14// 싼 모델이 헤맨다는 신호: 결과를 거부하는 말
15const REJECT = /(그대로|여전히|안 ?바뀌|아니야|아니라|그게 아니|틀렸|틀린|안 ?돼|안 ?되|다시 해|왜 안|못 ?알아|still|didn'?t work|doesn'?t work|not what)/i
16
17/**
18 * 요청 문장의 무게. lastAnswer 는 직전 답의 끝부분 (그게 질문·제안으로 끝났으면 짧은 긍정은 승인이다).
19 * 'heavy' 무거운 일 · 'approve' 승인·진행 지시 (무거운 일로 친다) · 'ack' 맞장구만 (그대로 둔다)
20 * · 'light' 새 일 없는 기억 확인 질문 (캐시가 식었거나 대화가 짧으면 내릴 후보) · 'unsure' 애매함 (그대로)
21 */
22export function ruleOf(text, lastAnswer = '') {
23  const t = String(text || '').trim()
24  if (!t) return 'unsure'
25  if (HEAVY.test(t) || t.length > 120) return 'heavy'
26  if (t.length <= 40 && APPROVE.test(t)) return 'approve' // 'ㅇㅋ 진행해' 처럼 섞여도 승인이 먼저
27  if (ACK.test(t)) return ASKS_BACK.test(String(lastAnswer || '').trim()) ? 'approve' : 'ack'
28  if (t.length <= 40 && LIGHT.test(t)) return 'light'
29  return 'unsure'
30}
31export const isRejection = text => REJECT.test(String(text || ''))
32
33// 서브에이전트 중 검색·탐색류 (Haiku 로 보낸다)
34const SEARCHY = /(explore|search|find|locate|grep|lookup|look up|찾|검색|탐색|훑어|위치)/i
35export const isSearchy = (subagentType, description) => SEARCHY.test(`${subagentType || ''} ${description || ''}`)
36
37export const MODELS = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }
38export const familyOf = model => {
39  const m = String(model || '')
40  return /haiku/.test(m) ? 'haiku' : /sonnet/.test(m) ? 'sonnet' : /fable|mythos/.test(m) ? 'fable' : 'opus'
41}
42export const prettyName = model => {
43  const m = String(model || '').replace(/^claude-/, '').replace(/\[.*\]$/, '')
44  const [fam, ...v] = m.split('-')
45  return fam ? fam[0].toUpperCase() + fam.slice(1) + (v.length ? ' ' + v.join('.') : '') : '?'
46}
47
48// 단가 (달러 / 100만 토큰): 입력, 캐시 읽기, 출력. 캐시 쓰기는 1시간 TTL 이라 입력의 2배.
49export function priceOf(model, ctx = 0) {
50  const m = String(model || '')
51  if (/haiku-5/.test(m)) return ctx > 100000 ? { input: 0.5, read: 0.05, output: 2.5 } : { input: 0.1, read: 0.01, output: 0.5 }
52  if (/haiku/.test(m)) return { input: 1, read: 0.1, output: 5 }
53  if (/sonnet-4/.test(m)) return { input: 3, read: 0.3, output: 15 }
54  if (/sonnet/.test(m)) return { input: 2, read: 0.2, output: 10 }
55  if (/fable|mythos/.test(m)) return { input: 10, read: 0.25, output: 50 }
56  if (/opus-5-5/.test(m)) return { input: 4, read: 0.2, output: 20 }
57  return { input: 5, read: 0.5, output: 25 }
58}
59
60/** 한 턴의 어림값: 대화를 캐시에서 읽거나(warm) 다시 쓰고(cold), 답을 outTokens 만큼 쓰는 값 */
61export function turnCost(model, ctx, warm, outTokens = 1500) {
62  const p = priceOf(model, ctx)
63  return (ctx / 1e6) * (warm ? p.read : p.input * 2) + (outTokens / 1e6) * p.output
64}
65
66// ---- 3단계: 일꾼에게 맡길 일인지 ----
67// 판단이 필요한 일: 분석·검증·설계·원인 찾기, 코드 고치기, 비교·추천 (Opus)
68const JUDGE = /(분석|검증|왜|원인|설계|판단|고민|반영|확인하고|비교|리뷰|검토|어떻게 생각|추천|계획|전략|디버그|버그|에러|오류|고쳐|고치|수정해|리팩|구현|최적화|implement|refactor|debug|fix|review|analy[sz]e|design)/i
69// 대화 속 무언가를 가리키는 말: 일꾼은 대화 전체를 안 보니 대상이 흐려진다 (Opus)
70const DEICTIC = /(이거|그거|저거|이걸|그걸|저걸|이것|그것|아까|위에|방금|앞에서|말한|얘기한|그대로)/
71// 원래 기계적인 쓰기 동사 (Sonnet): 옮기고, 붙이고, 이름 바꾸고, 더하기
72const MECH = /(복사해|옮겨|이름 ?바꿔|이름을 바꿔|붙여|추가해|넣어|기록해|메모해|정렬해|포맷)/
73// 만드는 동사: 큰 구현·작성일 때가 많아서, '어디에 + 무엇을'이 함께 있을 때만 일꾼 (아니면 Opus)
74const GEN = /(만들어 ?줘|작성해|써 ?줘|생성해|저장해)/
75// 목적지 ('노션에', '파일에', 'README.md에' …)
76const DEST = /(노션에|노션 페이지에|파일에|메모에|메모장에|\.\w{1,5}\s*에|시트에|페이지에|문서에|폴더에|README에)/
77// 넣을 대상이 주어짐 ('이 텍스트', '아래 내용', '다음을', 따옴표·코드블록, '…: 내용')
78const TARGET = /(이 텍스트|이 문장|아래 내용|아래 메모|아래 글|다음을|다음 내용|["'“”‘’`]|```|:\s*\S)/
79// 산출물 명사: 이게 붙은 만들기·추가는 구현·작성이라 판단이 필요하다 (Opus)
80const PRODUCT = /(기능|코드|테스트|플러그인|화면|api|보고서|스크립트|모듈|함수|클래스|컴포넌트|앱|서비스|설계서|기획|로직|쿼리|프로그램)/i
81// 찾기·읽기만 하는 일 (Haiku): 목록, 위치, 내용 보여 주기
82const SEARCH = /(찾아|검색|어디 ?있|어디에 있|목록|리스트|뭐가 있|무엇이 있|뭐 있|보여 ?줘|알려 ?줘|몇 개|개수|읽어 ?줘|열어 ?봐)/
83
84/** 'write' (Sonnet 일꾼) · 'search' (Haiku 일꾼) · 'judgment' (Opus) · 'unsure' (Opus). 확실할 때만 일꾼이다 */
85export function taskOf(text, lastAnswer = '') {
86  const t = String(text || '').trim()
87  if (!t) return 'unsure'
88  const rule = ruleOf(t, lastAnswer)
89  if (rule === 'approve') return 'judgment' // 직전 제안을 실행하라는 말: 판단 맥락이 필요하다
90  if (JUDGE.test(t) || t.length > 160) return 'judgment'
91  if ((GEN.test(t) || MECH.test(t)) && PRODUCT.test(t) && !(DEST.test(t) && TARGET.test(t))) return 'judgment'
92  if (DEICTIC.test(t)) return 'unsure'
93  if (MECH.test(t)) return 'write'
94  if (GEN.test(t)) return DEST.test(t) && TARGET.test(t) ? 'write' : 'unsure'
95  if (SEARCH.test(t)) return 'search'
96  return 'unsure'
97}
98
99/** 한 턴(여러 단계)의 실제 값: 사용량 합계로 (캐시 쓰기는 1시간 TTL 이라 입력의 2배로 친다) */
100export function usageCost(model, u) {
101  if (!u) return 0
102  const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
103  const p = priceOf(model, ctx)
104  return ((u.input_tokens || 0) * p.input + (u.cache_read_input_tokens || 0) * p.read + (u.cache_creation_input_tokens || 0) * p.input * 2 + (u.output_tokens || 0) * p.output) / 1e6
105}
106// 일꾼 한 번의 어림값: 새 대화 약 3만 토큰을 쓰고, 답 약 800 토큰 (시제품 실측: Haiku 약 $0.003, Sonnet 3만 쓰기)
107export const workerEstimate = model => turnCost(model, 30000, false, 800)
108
109// ---- 사후 판정: Opus 가 처리한 턴이 사실 일꾼으로 충분했는지 (그 턴의 실제 행동으로, 모델 호출 없이) ----
110const WRITE_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit|mcp__.*(create|update|append|add|insert|write|post|comment).*)$/i
111const SEARCH_TOOLS = /^(Read|Glob|Grep|LS|WebFetch|WebSearch|mcp__.*(fetch|search|get|list|read|query).*)$/i
112/**
113 * tools: 그 턴의 메인 도구 호출 [{ tool, isError }], usage: 그 턴 사용량 합계.
114 * 도구 1~3번, 모두 단순 쓰기·찾기, 오류 없음, 출력 짧음(생각 포함 1500 토큰 이하) → { task, why }. 아니면 null
115 */
116export function missedWorkerOf(tools, usage) {
117  if (!tools || !tools.length || tools.length > 3) return null
118  if (tools.some(t => t.isError)) return null
119  if (!usage || (usage.output_tokens || 0) > 1500) return null
120  const writes = tools.filter(t => WRITE_TOOLS.test(t.tool)).length
121  const reads = tools.filter(t => SEARCH_TOOLS.test(t.tool)).length
122  if (writes + reads !== tools.length) return null // Bash, 서브에이전트, 그 밖의 도구가 끼면 Opus 가 맞다고 본다
123  if (writes > 2) return null
124  return { task: writes ? 'write' : 'search', why: `도구 ${tools.map(t => t.tool.replace(/^mcp__[^_]+__/, '')).join(', ')} · 출력 ${usage.output_tokens || 0} 토큰` }
125}
126/** 기록용 요청 앞부분: 공백을 접고 80자까지 (붙여 넣은 긴 글은 잘린다) */
127export const promptHead = text => String(text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
128
129// ---- 절약 어림값 (API 환산, 구독제에서는 사용량 게이지를 아낀 만큼) ----
130/** 일꾼이 맡은 일을 원래 모델이 했다면: 첫 단계는 대화를 읽거나(캐시 따뜻) 다시 쓰고(식음), 나머지 단계는 캐시로 읽고, 답 약 800 토큰 */
131export function opusWouldWorker(model, ctx, warm, task) {
132  const p = priceOf(model, ctx)
133  const steps = task === 'write' ? 3 : 2
134  return (ctx / 1e6) * (warm ? p.read : p.input * 2) + ((steps - 1) * ctx / 1e6) * p.read + (800 / 1e6) * p.output
135}
136/** 일꾼 턴 뒤 원래 모델이 붙은 부분(요청 + 일꾼 답)을 새로 쓰는 값: 글자 약 2.5자 = 1 토큰 */
137export function appendCost(model, chars) {
138  return ((chars / 2.5) / 1e6) * priceOf(model, 0).input * 2
139}
140/** 쉬고 와서 압축을 원래 모델이 했다면: 대화 전체를 다시 쓰고(식음) 요약을 쓰는 값 */
141export function opusWouldCompact(model, ctx, outTokens) {
142  const p = priceOf(model, ctx)
143  return (ctx / 1e6) * p.input * 2 + ((outTokens || 6000) / 1e6) * p.output
144}
145
types/index.d.ts 69 lines
1// auto-model 이 세션 동안 들고 있는 값. usage-meter 가 hint 를 읽어 테리 카드 아래에 그린다.
2export type AutoModelHint = {
3  /** cold: 쉬고 와서 캐시가 식음 (압축하면 Sonnet 요약) · warm: 작업 중 대화가 많이 참 (원래 모델이 싸게 압축) */
4  kind?: 'cold' | 'warm'
5  /** warm 일 때 대화가 찬 정도 (%) */
6  percent?: number
7  /** 이 제안이 가리키는 마지막 모델 호출 시각 (ms). 같은 쉼에 대한 제안은 같은 id */
8  id: number
9  /** 마지막 모델 호출 뒤 지난 시간 (분) */
10  idleMinutes: number
11  /** 다음 요청이 다시 보내는 대화 크기 (토큰) */
12  tokens: number
13  /** 그 대화를 캐시에 다시 쓰는 값, API 환산 달러 (1시간 캐시 쓰기 단가) */
14  rewriteUsd: number
15  /** 마지막으로 답한 모델 id */
16  model: string
17}
18
19// 자동 전환 상태. usage-meter 가 카드 모델 줄에 그린다.
20export type AutoModelRoute = {
21  /** auto: 자동 전환 중 · pin: 고정 · stopped: /model 로 직접 골라 멈춤 · off: 꺼짐 */
22  mode: 'auto' | 'pin' | 'stopped' | 'off'
23  /** 사람이 고른 세션 모델 */
24  base: string
25  /** 이번 요청에 쓰는 모델 */
26  model: string
27  /** 왜 그 모델인지 한 줄 */
28  reason: string
29}
30
31// 판단 근거: 모듈 변수는 /reload-plugins 때 비워지므로, 세션 상태에도 적어 두고 같은 세션이면 되살린다
32export type AutoModelMemory = {
33  sessionId: string
34  last: { at: number; tokens: number; model: string } | null
35  warmAt: Record<string, number>
36  current: string | null
37  sessionBase: string | null
38  stopped: boolean
39  cooldown: number
40  /** 메인 대화의 직전 답 끝부분 (짧은 긍정이 승인인지 볼 때) */
41  lastAnswer: string
42}
43
44// 카드에 보이는 절약: 이 대화에서 아낀 비율 (PC 합계·게이지 환산은 /auto-model 상세)
45export type AutoModelSaving = {
46  usdToday: number
47  usdWeek: number
48  /** 오늘 아낀 만큼의 5시간 게이지 % (보정 전엔 null) */
49  fivePct: number | null
50  /** 이번 주간 창에서 아낀 만큼의 주간 게이지 % (보정 전엔 null) */
51  weekPct: number | null
52  calibrated: boolean
53  /** 오늘 이 컴퓨터에서 내 사용량 대비 아낀 비율 (%): 순절약 / (실제 쓴 값 + 순절약) */
54  myPct: number | null
55  /** 이 대화(세션)에서 아낀 금액 (API 환산) */
56  sessionUsd: number
57  /** 이 대화에서 아낀 비율 (%): 순절약 / (이 대화 실제 비용 + 순절약). 실제 비용이 $1 미만이면 null */
58  sessionPct: number | null
59}
60
61// /auto-model compact 를 usage-meter 에게 부탁: 우리가 시작한 압축은 우리 훅이 못 가로채서, usage-meter 가 이걸 보고 시작한다
62export type AutoModelAsk = { id: number; at: number }
63
64declare module 'claude-code' {
65  interface PluginState {
66    'auto-model': { hint: AutoModelHint | null; route: AutoModelRoute | null; memory: AutoModelMemory | null; saving: AutoModelSaving | null; ask: AutoModelAsk | null }
67  }
68}
69