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

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
프롬프트 위에 5시간 · 주간 · 대화 사용량을 보여 주고, 80% · 90% 에서 알려 줍니다.
/terry
응답하는 동안 테리는 지금 추론 강도(/effort)에 맞춰 움직입니다. max 에선 빨간 망토를 휘날리며 날아요. /terry run max 처럼 강도를 붙이면 20초 동안 미리 볼 수 있어요.

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

/terry pet 하면 좋아해요. 쓰다듬은 횟수는 /terry stats 에 남아요./terry catch · droop · door · stretch · eat 로 미리 볼 수 있어요./flame1
| 명령 | 내용 |
|---|---|
/flame1 | 파란 불꽃 사용량 밴드 + 모델 카드 |
/terry | 입력·응답에 반응하는 베들링턴 테리어, 이번 요청의 시간·토큰 카드, 사용량 표 |
/terry run | 20초 동안 달리는 모습 미리보기 (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
node docs/preview/terry-view.mjs # ←→ 동작, e 강도, m 그림 방식, w 너비, t 시각, s 계절, y 명절, space 느리게, q 끝내기
요청마다 맞는 모델이 처리해요. 메인(Opus)은 그대로 두고 단순한 일은 Sonnet·Haiku 일꾼에게 맡겨서, 기억을 안 깨고 사용료를 아껴요. 팀장을 바꾸는 게 아니라 일마다 맞는 팀원에게 배정하는 셈이에요. 메인 모델을 바꾸지 않는 이유: 캐시는 모델마다 따로이고 마지막 호출 뒤 1시간이 지나면 식어서, 긴 대화에서 함부로 모델을 바꾸면 대화 전체를 다시 저장하느라 오히려 비싸집니다. usage-meter 와 같이 쓰면 테리 카드에 표시됩니다.
[압축] [계속]). 이때 [압축] 은 Sonnet 이 요약합니다(시험에서 Opus 와 같은 품질에 절반 값).[압축] [나중에], 나중에는 5%p 더 찰 때까지 안 물음). 이때는 원래 모델이 압축합니다.[압축] 으로 하는 압축에는 사용자가 보낸 메시지 원문(긴 붙여넣기는 앞부분만)을 붙여 승인·금지 범위가 흐려지지 않게 합니다. 직접 친 /compact 와 자동 압축은 그대로입니다./auto-model light on 으로 켜기).shadow), /auto-model worker on 으로 켜기. @@ 요청(Sonnet) · @@h 요청(Haiku) 은 손으로 바로 일꾼에게.💰 이 대화 약 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 | 작업 중 압축 알림 기준(%) |
입력이 "기존 화면처럼 해 달라"(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 에 설치)hooks/register.js 1086 lines1// 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}
1086hooks/route.js 145 lines1// 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}
145types/index.d.ts 69 lines1// 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