claude-token-saver 3.7.0 → 3.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -86,6 +86,7 @@ Run these in your shell (inside Claude Code, the `/claude-token-saver` Skill is
86
86
  | `claude-token-saver mode [keywords...]` | Output config (`icon`/`text`, `en`/`ko`, `1h`–`30d` window, …) |
87
87
  | `claude-token-saver harness ...` | 🅷 Harness management (below) |
88
88
  | `claude-token-saver route-scan` | Detect recurring easy work on expensive models → propose haiku-delegation ratchet rules (below) |
89
+ | `claude-token-saver compact-window` | Warn when a 1M-context session has no auto-compact cap → pin 400k with `set` (below) |
89
90
  | `claude-token-saver install` | Manually register Skill + statusline |
90
91
 
91
92
  Switch output language with `mode ko` / `mode en` (English default; statusline chips stay symbolic).
@@ -141,6 +142,25 @@ An auto `.bak` is kept, but **the session context that earned the rule its place
141
142
  </details>
142
143
 
143
144
 
145
+ ## 📦 compact-window — pin where a 1M session compacts
146
+
147
+ Claude Code compacts when usage approaches `min(autoCompactWindow, model max context)`. On a 1M window, with that value unset, compaction only fires near 800k — and until then every request re-bills the whole context. Pinning 400k keeps twice a 200k session's headroom for the genuinely large pastes while cutting off the runaway tail.
148
+
149
+ **200k sessions are never warned** — their window is already at or below 200k, so the setting cannot change anything.
150
+
151
+ ```bash
152
+ claude-token-saver compact-window # status (model, window, value, source)
153
+ claude-token-saver compact-window set --global # pin 400k in ~/.claude/settings.json
154
+ claude-token-saver compact-window set --project # pin it in <root>/.claude/settings.json
155
+ claude-token-saver compact-window set --global --value 250k # explicit value (100k–1M)
156
+ claude-token-saver compact-window off | on # toggle the warning
157
+ ```
158
+
159
+ - On a 1M model with the value unset or above 400k, the statusline shows `🅷⚠ compact-window?` and the session briefing hands the model the exact registration command.
160
+ - Scope (`--global`/`--project`) is **required** for `set` — a global settings file is never edited on a guess.
161
+ - Every other key in `settings.json` is preserved and a `.bak` is written first. Malformed JSON aborts the write untouched.
162
+ - An exported `CLAUDE_CODE_AUTO_COMPACT_WINDOW` beats settings.json; `set` detects that and says so.
163
+
144
164
  ## 🔀 route-scan — "this recurring task could run on a cheaper tier"
145
165
 
146
166
  Finds the easy work your expensive model (opus/fable) keeps redoing in your session logs and proposes **haiku/sonnet delegation rules**. Fully local, zero token cost.
@@ -243,6 +263,12 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
243
263
 
244
264
  ## Release notes
245
265
 
266
+ ### v3.8.1 (2026-07-31)
267
+ - **Fixed: 1M sessions were judged against a 200k window** — the briefing inferred the window from the largest request seen so far, so a 1M session counted as 200k until it had already grown past 250k. At 160k of input it announced "past 80% of the 200k window" — really 16%. The window now comes from the configured model id, and when `autoCompactWindow` is set that is where the session actually turns over, so the percentage is measured against it (the text says `(autoCompactWindow 기준)`). The observed-size heuristic remains only as the fallback for an unreadable model id.
268
+
269
+ ### v3.8.0 (2026-07-31)
270
+ - **New `compact-window` — 1M sessions had no compaction cap** — Claude Code compacts near `min(autoCompactWindow, model max context)`. On a 1M window with that value unset, compaction only fires around 800k, and every request until then re-bills the entire context. A 1M model with the value unset or above 400k now raises `🅷⚠ compact-window?` on the statusline plus a session briefing, and `compact-window set --global|--project` pins 400k. 200k sessions are exempt — the setting cannot change anything for them.
271
+
246
272
  ### v3.7.0 (2026-07-29)
247
273
  - **Fixed: delegation rules named subagents that may not exist** — generated T2 rules hard-coded preset names like `haiku-explore` / `haiku-runner`, which live in each user's own `~/.claude/agents/` and are not shipped by this package. On a machine without them, the rule told the model to delegate to a nonexistent agent. The default phrasing is now `model: haiku` (matching T1's `model: sonnet`), and the name is added only when the agent file is really present, e.g. `haiku-explore(model: haiku)`. Project-level `.claude/agents/` counts too. `ratchet-model.md` re-renders with the new phrasing on the next `route-scan`.
248
274
 
package/README.md CHANGED
@@ -86,6 +86,7 @@ Claude 안에서 `/claude-token-saver` Skill을 실행하거나 칩 문구를
86
86
  | `claude-token-saver mode [keywords...]` | 출력 설정 (`icon`/`text`, `ko`/`en`, `1h`~`30d` 윈도 등) |
87
87
  | `claude-token-saver harness ...` | 🅷 Harness 관리 (아래 참고) |
88
88
  | `claude-token-saver route-scan` | 상위 모델이 반복 처리한 easy 작업 감지 → haiku 위임 랫쳇 룰 제안 (아래 참고) |
89
+ | `claude-token-saver compact-window` | 1M 컨텍스트인데 자동 압축 창이 안 잡혀 있으면 경고 → `set`으로 40만 고정 (아래 참고) |
89
90
  | `claude-token-saver install` | Skill·statusline 수동 등록 |
90
91
 
91
92
  출력 언어는 `mode ko` / `mode en`으로 전환합니다 (기본 영어, statusline 칩은 항상 기호). 전체 옵션은 [영문 README](./README.en.md#options) 참고.
@@ -122,6 +123,25 @@ ratchet의 가치는 **한 방향 누적**에 있습니다. 룰을 가볍게 지
122
123
  </details>
123
124
 
124
125
 
126
+ ## 📦 compact-window — 1M 컨텍스트의 자동 압축 지점 고정
127
+
128
+ Claude Code는 `min(autoCompactWindow, 모델 최대 창)`에 가까워지면 대화를 자동 압축합니다. 1M 창을 쓰면 이 값이 잡혀 있지 않은 한 80만 토큰 근처까지 가서야 압축이 걸리고, 그전까지 모든 요청이 전체 컨텍스트를 통째로 재과금합니다. 40만으로 고정하면 큰 붙여넣기용 여유는 200k 세션의 두 배로 남기면서 꼬리만 잘라냅니다.
129
+
130
+ **200k 컨텍스트는 경고 대상이 아닙니다** — 창이 이미 200k 이하라 이 설정이 바꿀 게 없습니다.
131
+
132
+ ```bash
133
+ claude-token-saver compact-window # 현재 상태 (모델·창·설정값·출처)
134
+ claude-token-saver compact-window set --global # ~/.claude/settings.json 에 40만 고정
135
+ claude-token-saver compact-window set --project # <root>/.claude/settings.json 에 고정
136
+ claude-token-saver compact-window set --global --value 250k # 값 직접 지정 (10만~1M)
137
+ claude-token-saver compact-window off | on # 경고 표시 토글
138
+ ```
139
+
140
+ - 1M 모델인데 미설정이거나 40만을 넘으면 statusline에 `🅷⚠ compact-window?`가 뜨고, 세션 브리핑이 등록 명령까지 알려줍니다.
141
+ - 스코프(`--global`/`--project`)는 `set`에서 **필수** — 글로벌 설정 파일을 묻지 않고 고치지 않기 위한 설계입니다.
142
+ - 기존 `settings.json`의 다른 키는 그대로 보존하고 `.bak`을 남깁니다. JSON이 깨져 있으면 아무것도 쓰지 않고 중단합니다.
143
+ - 셸에 `CLAUDE_CODE_AUTO_COMPACT_WINDOW`가 export돼 있으면 그쪽이 settings.json보다 우선합니다 (`set`이 이 경우를 감지해 알려줍니다).
144
+
125
145
  ## 🔀 route-scan — "이 반복 작업, 더 싼 티어로 내려도 됩니다"
126
146
 
127
147
  세션 로그에서 상위 모델(opus/fable)이 반복 처리해 온 쉬운 작업을 찾아 **haiku/sonnet 위임 룰로 승격**을 제안합니다. 전 과정 로컬, 토큰 비용 0.
@@ -200,6 +220,12 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
200
220
 
201
221
  ## 릴리스 노트
202
222
 
223
+ ### v3.8.1 (2026-07-31)
224
+ - **1M 세션을 200k 창으로 오판하던 브리핑 버그 수정** — 세션 창을 "지금까지 본 가장 큰 요청"으로 추정해서, 1M 세션이라도 25만 토큰을 넘기 전까지는 200k로 취급했습니다. 그래서 입력 160k에서 "200k 창의 80%를 넘었습니다" 경고가 떴습니다(실제로는 16%). 이제 설정된 모델 ID로 창을 판정하고, `autoCompactWindow`가 잡혀 있으면 그 값이 실제로 세션이 넘어가는 지점이므로 그쪽을 기준으로 %를 계산합니다(문구에도 `(autoCompactWindow 기준)` 표기). 모델 ID를 못 읽는 경우에만 기존 관측치 추정으로 되돌아갑니다.
225
+
226
+ ### v3.8.0 (2026-07-31)
227
+ - **`compact-window` 추가 — 1M 컨텍스트에서 자동 압축 지점이 방치되던 문제** — Claude Code는 `min(autoCompactWindow, 모델 최대 창)` 근처에서 압축합니다. 1M 창을 쓰면 이 값을 안 잡는 한 80만 토큰까지 커진 뒤에야 압축이 걸리고, 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 이제 1M 모델인데 미설정이거나 40만 초과면 statusline `🅷⚠ compact-window?` + 세션 브리핑으로 알리고, `compact-window set --global|--project`로 40만을 고정합니다. 200k 컨텍스트는 설정이 영향을 주지 않으므로 경고 대상에서 제외합니다.
228
+
203
229
  ### v3.7.0 (2026-07-29)
204
230
  - **위임 룰이 없는 서브에이전트를 가리키던 문제 수정** — 생성되는 T2 룰이 `haiku-explore`·`haiku-runner` 같은 이름을 직접 적었는데, 이 preset 에이전트들은 각자의 `~/.claude/agents/`에 있는 것이라 패키지가 배포하지 않습니다. 그래서 해당 파일이 없는 환경에서는 "존재하지 않는 에이전트로 위임하라"는 룰이 자동 생성됐습니다. 이제 기본 표현은 `model: haiku`(T1의 `model: sonnet`과 통일)이고, 에이전트 파일이 실제로 있을 때만 `haiku-explore(model: haiku)`처럼 이름을 병기합니다. 프로젝트 `.claude/agents/`도 인식합니다. 다음 `route-scan` 때 `ratchet-model.md`가 새 표현으로 다시 렌더됩니다.
205
231
 
package/bin/cli.js CHANGED
@@ -145,6 +145,17 @@ async function main() {
145
145
  return (await import('../src/commands/harness.js')).run({ args, hasFlag });
146
146
  }
147
147
 
148
+ // Subcommand: compact-window — audit / pin Claude Code's autoCompactWindow.
149
+ // On a 1M-context model, compaction only fires near 800k unless the window is
150
+ // capped; 200k sessions are exempt.
151
+ // claude-token-saver compact-window # status
152
+ // claude-token-saver compact-window set --global # pin 200k (~/.claude/settings.json)
153
+ // claude-token-saver compact-window set --project # pin 200k (<root>/.claude/settings.json)
154
+ // claude-token-saver compact-window off | on # toggle the statusline warning
155
+ if (args[0] === 'compact-window') {
156
+ return (await import('../src/commands/compact-window.js')).run({ args, hasFlag });
157
+ }
158
+
148
159
  // Hook management
149
160
  if (hasFlag('--install-hook')) {
150
161
  const { installHook } = await import('../src/hook-manager.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-token-saver",
3
- "version": "3.7.0",
3
+ "version": "3.8.1",
4
4
  "description": "Save tokens on Claude Code — spike diagnosis, 1M-context detection, TTL countdown, statusline. (formerly claude-cache-monitor)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brief.js CHANGED
@@ -28,6 +28,7 @@
28
28
  import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
29
29
  import { join } from 'node:path';
30
30
  import { userDataDir } from './paths.js';
31
+ import { resolveModelId, isOneMillionModel, effectiveWindow } from './compact-window.js';
31
32
 
32
33
  // Context tiers as a fraction of the session's context window. Tier 1 warns
33
34
  // (compaction/cost territory ahead), tier 2 urges wrapping up. A session only
@@ -36,7 +37,9 @@ export const CTX_TIERS = [
36
37
  { tier: 1, pct: 0.8 },
37
38
  { tier: 2, pct: 0.95 },
38
39
  ];
39
- // Requests above this input size can only exist on a 1M window.
40
+ // Requests above this input size can only exist on a 1M window. Used as a
41
+ // fallback signal only — a 1M session that has not yet grown past 250k is
42
+ // still a 1M session, so the configured model decides first (see sessionCtx).
40
43
  const WINDOW_1M_MIN_INPUT = 250_000;
41
44
  const PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
42
45
  const TAIL_BYTES = 256 * 1024;
@@ -67,11 +70,40 @@ function saveState(state, now) {
67
70
  writeFileSync(briefStatePath(), JSON.stringify(state) + '\n');
68
71
  }
69
72
 
73
+ /**
74
+ * The window a tier crossing should be measured against.
75
+ *
76
+ * Two corrections over "infer from the biggest request seen":
77
+ * - The configured model decides the ceiling. A 1M session that has not yet
78
+ * grown past 250k is still a 1M session; judging it against 200k fired the
79
+ * 80% warning at 160k, less than a fifth of the real window.
80
+ * - `autoCompactWindow` lowers that ceiling. Once compaction is pinned at
81
+ * 400k, 400k — not 1M — is where the session actually turns over, so that
82
+ * is the number a "you are at 80%" warning has to mean.
83
+ *
84
+ * `observedMax` stays as a floor: it proves a 1M window even when the model id
85
+ * is unreadable (env override, settings we do not resolve).
86
+ */
87
+ export function ctxWindowFor(observedMax = 0, root = process.cwd()) {
88
+ let window = observedMax > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
89
+ let compactCapped = false;
90
+ try {
91
+ const { model } = resolveModelId(root);
92
+ if (isOneMillionModel(model)) window = 1_000_000;
93
+ const cap = effectiveWindow(root).value;
94
+ if (cap !== null && cap < window) {
95
+ window = cap;
96
+ compactCapped = true;
97
+ }
98
+ } catch { /* settings unreadable — the observed-size fallback still holds */ }
99
+ return { window, compactCapped };
100
+ }
101
+
70
102
  /**
71
103
  * Last request's input size for THIS session, from the transcript tail.
72
104
  * Reads at most TAIL_BYTES — prompt-submit hooks must stay fast.
73
105
  */
74
- export function sessionCtx(transcriptPath) {
106
+ export function sessionCtx(transcriptPath, { root = process.cwd() } = {}) {
75
107
  let size;
76
108
  try { size = statSync(transcriptPath).size; } catch { return null; }
77
109
  const start = Math.max(0, size - TAIL_BYTES);
@@ -97,8 +129,8 @@ export function sessionCtx(transcriptPath) {
97
129
  if (total > 0) { input = total; maxInput = Math.max(maxInput, total); }
98
130
  }
99
131
  if (input == null) return null;
100
- const window = maxInput > WINDOW_1M_MIN_INPUT ? 1_000_000 : 200_000;
101
- return { input, window, pct: input / window };
132
+ const { window, compactCapped } = ctxWindowFor(maxInput, root);
133
+ return { input, window, compactCapped, pct: input / window };
102
134
  }
103
135
 
104
136
  function ctxTierOf(pct) {
@@ -144,10 +176,14 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
144
176
  if (ctx) {
145
177
  const tier = ctxTierOf(ctx.pct);
146
178
  if (tier > (s.ctxTier || 0)) {
147
- const winLabel = ctx.window >= 1_000_000 ? '1M' : '200k';
179
+ // Name the window the percentage was actually computed against — when
180
+ // autoCompactWindow caps a 1M model at 400k, "1M 창의 80%" would be a
181
+ // number the user cannot reconcile with anything they configured.
182
+ const winLabel = ctx.window >= 1_000_000 ? '1M' : fmtK(ctx.window);
183
+ const capNote = ctx.compactCapped ? ' (autoCompactWindow 기준)' : '';
148
184
  items.push(tier === 2
149
- ? `이 세션의 컨텍스트가 ${winLabel} 창의 95%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 곧 자동 압축으로 맥락 손실이 생길 수 있으니, 진행 중인 작업을 일단락하고 새 세션을 시작하는 편이 좋습니다.`
150
- : `이 세션의 컨텍스트가 ${winLabel} 창의 80%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 이후 요청은 비용이 커지는 구간입니다 — 작업이 일단락되면 새 세션 시작을 권합니다.`);
185
+ ? `이 세션의 컨텍스트가 ${winLabel} 창${capNote}의 95%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 곧 자동 압축으로 맥락 손실이 생길 수 있으니, 진행 중인 작업을 일단락하고 새 세션을 시작하는 편이 좋습니다.`
186
+ : `이 세션의 컨텍스트가 ${winLabel} 창${capNote}의 80%를 넘었습니다(직전 요청 입력 ${fmtK(ctx.input)}). 이후 요청은 비용이 커지는 구간입니다 — 작업이 일단락되면 새 세션 시작을 권합니다.`);
151
187
  s.ctxTier = tier;
152
188
  }
153
189
  }
@@ -180,6 +216,25 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
180
216
  s.briefed = [...briefed];
181
217
  } catch { /* caches unreadable — ctx briefing above still applies */ }
182
218
 
219
+ // ── auto-compact window misconfigured on a 1M model (once per session) ──
220
+ // Config defect, not a usage trend: on a 1M window compaction only fires
221
+ // past ~800k, so every request until then re-bills a context the session
222
+ // never needed. 200k sessions are exempt (their window is already <= 200k).
223
+ try {
224
+ const cw = await import('./compact-window.js');
225
+ const st = cw.compactWindowStatus({ root: process.cwd() });
226
+ if (!st.ok) {
227
+ const sig = `compact-window|${st.reason}`;
228
+ const briefed = new Set(s.briefed || []);
229
+ if (!briefed.has(sig)) {
230
+ briefed.add(sig);
231
+ s.briefed = [...briefed];
232
+ const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
233
+ items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 40만으로 고정하면 1M 창은 그대로 두고 압축 시점만 앞당깁니다. 등록: claude-token-saver compact-window set --global|--project (적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
234
+ }
235
+ }
236
+ } catch { /* settings unreadable — other briefings above still apply */ }
237
+
183
238
  s.ts = now;
184
239
  state.sessions[sessionId] = s;
185
240
  saveState(state, now);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Subcommand: compact-window — audit / pin Claude Code's `autoCompactWindow`.
3
+ * claude-token-saver compact-window # status
4
+ * claude-token-saver compact-window set --global # pin 400k in ~/.claude/settings.json
5
+ * claude-token-saver compact-window set --project # pin 400k in <root>/.claude/settings.json
6
+ * claude-token-saver compact-window set --global --value 250k
7
+ * claude-token-saver compact-window off | on # toggle the statusline warning
8
+ *
9
+ * Scope is deliberately explicit for `set`: writing a global settings.json is
10
+ * not something to guess at, and the non-TTY hook environment cannot prompt.
11
+ */
12
+
13
+ export async function run({ args, hasFlag }) {
14
+ const sub = args[1];
15
+ const cw = await import('../compact-window.js');
16
+ const { findProjectRoot } = await import('../harness.js');
17
+ const { loadConfig, saveConfig, userLanguage } = await import('../config.js');
18
+ const lang = userLanguage();
19
+ const root = findProjectRoot();
20
+ const ko = lang === 'ko';
21
+ const fmt = (n) => (n === null || n === undefined ? '-' : `${Math.round(n / 1000)}k`);
22
+
23
+ if (sub === 'off' || sub === 'on') {
24
+ const cfg = loadConfig();
25
+ cfg.compactWindow = cfg.compactWindow || {};
26
+ cfg.compactWindow.enabled = sub === 'on';
27
+ saveConfig(cfg);
28
+ console.log(`Statusline compact-window warning: ${sub}`);
29
+ return;
30
+ }
31
+
32
+ if (sub === 'set') {
33
+ const scope = hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null;
34
+ if (!scope) {
35
+ console.error(ko
36
+ ? '적용 범위를 명시하세요 (사용자에게 먼저 확인): --global (~/.claude/settings.json) 또는 --project (<root>/.claude/settings.json)'
37
+ : 'Scope required: --global (~/.claude/settings.json) or --project (<root>/.claude/settings.json)');
38
+ process.exit(1);
39
+ }
40
+ const argv = args.slice(2);
41
+ const i = argv.indexOf('--value');
42
+ const eq = argv.find((a) => a.startsWith('--value='));
43
+ const raw = i !== -1 && argv[i + 1] ? argv[i + 1] : (eq ? eq.slice('--value='.length) : null);
44
+ const r = cw.setAutoCompactWindow({ root, scope, value: raw ?? cw.RECOMMENDED_WINDOW });
45
+ if (!r.ok) { console.error(`❌ ${r.error}`); process.exit(1); }
46
+ console.log(`✅ autoCompactWindow = ${r.value} (${fmt(r.value)}) → ${r.path} [${r.scope}]`);
47
+ if (r.previous !== null && r.previous !== undefined) console.log(` previous: ${r.previous}`);
48
+ if (r.backup) console.log(` backup: ${r.backup}`);
49
+ // The env var beats settings.json, so a stale export silently defeats the
50
+ // write we just made — say so instead of letting the user wonder.
51
+ if (process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) {
52
+ console.log(ko
53
+ ? `\n⚠ 셸에 CLAUDE_CODE_AUTO_COMPACT_WINDOW=${process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW} 가 설정돼 있어 settings.json보다 우선합니다. unset 하세요.`
54
+ : `\n⚠ CLAUDE_CODE_AUTO_COMPACT_WINDOW=${process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW} is exported and takes precedence over settings.json. Unset it.`);
55
+ }
56
+ console.log(ko ? '\n새 세션부터 적용됩니다.' : '\nApplies from the next session.');
57
+ return;
58
+ }
59
+
60
+ if (!sub || sub === 'status' || sub === 'check') {
61
+ const s = cw.compactWindowStatus({ root });
62
+ console.log(`model: ${s.model || '(not set — Claude Code default)'}${s.modelSource ? ` [${s.modelSource}]` : ''}`);
63
+ console.log(`window: ${s.is1m ? '1M context' : '200k context'}`);
64
+ console.log(`autoCompactWindow: ${s.window === null ? '(unset)' : `${s.window} (${fmt(s.window)})`}${s.windowSource ? ` [${s.windowSource}${s.windowPath ? ` → ${s.windowPath}` : ''}]` : ''}`);
65
+ if (s.ok && s.reason === 'not-1m') {
66
+ console.log(ko
67
+ ? '\n✅ 200k 컨텍스트라 이 설정은 영향이 없습니다 (워닝 대상 아님).'
68
+ : '\n✅ 200k context — this setting changes nothing here (not warned).');
69
+ return;
70
+ }
71
+ if (s.ok) {
72
+ console.log(ko ? '\n✅ 압축 창이 400k 이하로 고정돼 있습니다.' : '\n✅ Compaction window is pinned at or below 400k.');
73
+ return;
74
+ }
75
+ console.log(ko
76
+ ? `\n⚠ 1M 컨텍스트인데 autoCompactWindow가 ${s.reason === 'unset' ? '설정되지 않았습니다' : `${fmt(s.window)}로 너무 큽니다`} — 자동 압축이 80만 토큰 근처에서야 걸립니다.`
77
+ : `\n⚠ 1M context with autoCompactWindow ${s.reason === 'unset' ? 'unset' : `at ${fmt(s.window)}`} — compaction only fires near 800k.`);
78
+ console.log(ko
79
+ ? ' 그 전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 40만으로 고정하면 1M 창은 유지하면서 압축 시점만 앞당깁니다.'
80
+ : ' Until then every request re-bills the whole context. Pinning 400k keeps the 1M window while capping the runaway tail.');
81
+ console.log('\n claude-token-saver compact-window set --global (~/.claude/settings.json)');
82
+ console.log(' claude-token-saver compact-window set --project (<root>/.claude/settings.json)');
83
+ console.log(ko ? ' (적용 범위는 사용자에게 먼저 확인할 것)' : ' (confirm the scope with the user first)');
84
+ return;
85
+ }
86
+
87
+ console.error(`Unknown compact-window subcommand: ${sub}`);
88
+ console.error('Usage: claude-token-saver compact-window [status|set --global|--project [--value 400k]|off|on]');
89
+ process.exit(1);
90
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * compact-window — auto-compact window audit for 1M-context sessions.
3
+ *
4
+ * Claude Code compacts when context usage approaches
5
+ * `min(settings.autoCompactWindow, model max context)`. On a 1M window the
6
+ * default lets a session grow past 800k before compaction ever fires, so every
7
+ * later request re-bills a context most sessions never needed. Pinning
8
+ * `autoCompactWindow: 400000` keeps twice the headroom of a 200k session for
9
+ * the genuinely large ones while capping the runaway tail.
10
+ *
11
+ * 200k sessions are exempt by design: their window is already <= 200k, so the
12
+ * setting changes nothing and a warning would be pure noise.
13
+ *
14
+ * Sources, highest precedence first (mirrors Claude Code's own resolution):
15
+ * 1. env CLAUDE_CODE_AUTO_COMPACT_WINDOW ("200000" | "200k" | "200")
16
+ * 2. <root>/.claude/settings.local.json
17
+ * 3. <root>/.claude/settings.json
18
+ * 4. ~/.claude/settings.json
19
+ */
20
+
21
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
22
+ import { join, dirname } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { debug } from './debug.js';
25
+
26
+ // Claude Code clamps autoCompactWindow to [100k, 1M] (settings schema:
27
+ // `.int().min(1e5).max(1e6)`), so anything outside that range is not a value
28
+ // the app would honor.
29
+ export const MIN_WINDOW = 100_000;
30
+ export const MAX_WINDOW = 1_000_000;
31
+ export const RECOMMENDED_WINDOW = 400_000;
32
+
33
+ // A model id whose context is the 1M variant. Claude Code spells it as a
34
+ // suffix on the model id (`claude-opus-5[1m]`); the beta header form
35
+ // (`context-1m`) shows up in SDK/env configs.
36
+ const ONE_M_RE = /\[1m\]|context-1m|1m-context/i;
37
+
38
+ export function userSettingsPath() {
39
+ return join(homedir(), '.claude', 'settings.json');
40
+ }
41
+
42
+ export function projectSettingsPaths(root) {
43
+ return [
44
+ join(root, '.claude', 'settings.local.json'),
45
+ join(root, '.claude', 'settings.json'),
46
+ ];
47
+ }
48
+
49
+ function readJson(path) {
50
+ try {
51
+ if (!existsSync(path)) return null;
52
+ return JSON.parse(readFileSync(path, 'utf8'));
53
+ } catch (e) {
54
+ debug('compact-window:read', e);
55
+ return null; // malformed settings — treat as "nothing configured"
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Parse the value forms Claude Code accepts for a window size: a plain token
61
+ * count, a `200k`/`1M` shorthand, or the bare-hundreds shorthand (`200` = 200k)
62
+ * its /config parser documents. Returns null when unparseable.
63
+ */
64
+ export function parseWindow(raw) {
65
+ if (typeof raw === 'number') return Number.isFinite(raw) ? Math.round(raw) : null;
66
+ if (typeof raw !== 'string') return null;
67
+ const s = raw.trim().toLowerCase();
68
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*([km]?)$/);
69
+ if (!m) return null;
70
+ const n = parseFloat(m[1]);
71
+ if (!Number.isFinite(n)) return null;
72
+ if (m[2] === 'k') return Math.round(n * 1_000);
73
+ if (m[2] === 'm') return Math.round(n * 1_000_000);
74
+ // Bare number: below the legal minimum it can only be the `200` = 200k form.
75
+ return n < MIN_WINDOW ? Math.round(n * 1_000) : Math.round(n);
76
+ }
77
+
78
+ /**
79
+ * The model id this project would launch with. Same precedence as the settings
80
+ * lookup, with ANTHROPIC_MODEL on top (Claude Code reads it as an override).
81
+ */
82
+ export function resolveModelId(root) {
83
+ if (process.env.ANTHROPIC_MODEL) return { model: process.env.ANTHROPIC_MODEL, source: 'env' };
84
+ for (const p of projectSettingsPaths(root)) {
85
+ const j = readJson(p);
86
+ if (j && typeof j.model === 'string') return { model: j.model, source: 'project', path: p };
87
+ }
88
+ const g = readJson(userSettingsPath());
89
+ if (g && typeof g.model === 'string') return { model: g.model, source: 'global', path: userSettingsPath() };
90
+ return { model: null, source: null };
91
+ }
92
+
93
+ export function isOneMillionModel(model) {
94
+ return typeof model === 'string' && ONE_M_RE.test(model);
95
+ }
96
+
97
+ /** Effective autoCompactWindow, with the source that won. */
98
+ export function effectiveWindow(root) {
99
+ const env = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
100
+ if (env) {
101
+ const v = parseWindow(env);
102
+ if (v !== null) return { value: v, source: 'env' };
103
+ }
104
+ for (const p of projectSettingsPaths(root)) {
105
+ const j = readJson(p);
106
+ const v = j ? parseWindow(j.autoCompactWindow) : null;
107
+ if (v !== null) return { value: v, source: 'project', path: p };
108
+ }
109
+ const gp = userSettingsPath();
110
+ const g = readJson(gp);
111
+ const gv = g ? parseWindow(g.autoCompactWindow) : null;
112
+ if (gv !== null) return { value: gv, source: 'global', path: gp };
113
+ return { value: null, source: null };
114
+ }
115
+
116
+ /**
117
+ * Full audit result. `ok` is true when there is nothing to warn about — either
118
+ * the session is not on a 1M model (exempt), or the window is already pinned at
119
+ * or below the recommended 400k.
120
+ *
121
+ * `reason` names why it is not ok: 'unset' (no autoCompactWindow anywhere) or
122
+ * 'too-large' (set, but above 400k — still lets context run away).
123
+ */
124
+ export function compactWindowStatus({ root = process.cwd() } = {}) {
125
+ const { model, source: modelSource } = resolveModelId(root);
126
+ const is1m = isOneMillionModel(model);
127
+ const win = effectiveWindow(root);
128
+ const base = {
129
+ model,
130
+ modelSource,
131
+ is1m,
132
+ window: win.value,
133
+ windowSource: win.source,
134
+ windowPath: win.path || null,
135
+ recommended: RECOMMENDED_WINDOW,
136
+ };
137
+ if (!is1m) return { ...base, ok: true, reason: 'not-1m' };
138
+ if (win.value === null) return { ...base, ok: false, reason: 'unset' };
139
+ if (win.value > RECOMMENDED_WINDOW) return { ...base, ok: false, reason: 'too-large' };
140
+ return { ...base, ok: true, reason: 'configured' };
141
+ }
142
+
143
+ /**
144
+ * Statusline chip text, or null when there is nothing to say. Kept to the same
145
+ * short shape as the other 🅷⚠ warnings so the line stays single-width.
146
+ */
147
+ export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null) {
148
+ if (cfg && cfg.compactWindow && cfg.compactWindow.enabled === false) return null;
149
+ try {
150
+ const s = compactWindowStatus({ root });
151
+ return s.ok ? null : 'compact-window?';
152
+ } catch (e) {
153
+ debug('compact-window:statusline', e);
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Write `autoCompactWindow` into a settings.json, preserving every other key.
160
+ * Scope 'global' → ~/.claude/settings.json, 'project' → <root>/.claude/settings.json.
161
+ * A `.bak` is written first whenever the file already exists.
162
+ *
163
+ * Returns { path, scope, value, previous, backup }.
164
+ */
165
+ export function setAutoCompactWindow({ root = process.cwd(), scope = 'global', value = RECOMMENDED_WINDOW } = {}) {
166
+ const v = parseWindow(value);
167
+ if (v === null || v < MIN_WINDOW || v > MAX_WINDOW) {
168
+ return { ok: false, error: `Invalid window: ${value} (expected ${MIN_WINDOW}–${MAX_WINDOW} tokens, e.g. 400000 or 400k)` };
169
+ }
170
+ const path = scope === 'global' ? userSettingsPath() : join(root, '.claude', 'settings.json');
171
+ let json = {};
172
+ let backup = null;
173
+ if (existsSync(path)) {
174
+ const raw = readFileSync(path, 'utf8');
175
+ try {
176
+ json = JSON.parse(raw);
177
+ } catch (e) {
178
+ debug('compact-window:parse', e);
179
+ // Refuse to overwrite a file we cannot parse — silently replacing a
180
+ // malformed settings.json would drop every other setting in it.
181
+ return { ok: false, error: `${path} is not valid JSON — fix it first (nothing was written).` };
182
+ }
183
+ backup = `${path}.bak`;
184
+ writeFileSync(backup, raw);
185
+ } else {
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ }
188
+ const previous = parseWindow(json.autoCompactWindow);
189
+ json.autoCompactWindow = v;
190
+ writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
191
+ return { ok: true, path, scope, value: v, previous, backup };
192
+ }
package/src/harness.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from './harness-templates.js';
24
24
  import { routeWarningForStatusline } from './route-scan.js';
25
25
  import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
26
+ import { compactWindowWarningForStatusline } from './compact-window.js';
26
27
 
27
28
  const require = createRequire(import.meta.url);
28
29
  function readHarnessState() {
@@ -533,6 +534,15 @@ export function harnessStatusForStatusline(cfg, { root } = {}) {
533
534
  // but carries no `@` import, so every promoted ratchet rule is dead weight.
534
535
  // One `harness init` re-run fixes it and the warning goes away for good.
535
536
  if (!warning && status.hasBlock && !(status.hasRatchetImport && status.hasModelRatchetImport)) warning = 'ratchet-unloaded';
537
+ // Same class of defect, one notch lower: the session runs on a 1M-context
538
+ // model with no `autoCompactWindow` cap, so compaction only fires past 800k
539
+ // and every request until then re-bills the whole context. 200k sessions are
540
+ // exempt — the setting cannot change anything for them.
541
+ if (!warning) {
542
+ try {
543
+ warning = compactWindowWarningForStatusline(projectRoot, cfg);
544
+ } catch { /* settings unreadable — stay silent */ }
545
+ }
536
546
  // Below session-quality warnings: a promoted delegation rule whose
537
547
  // category started failing (`rule-health R<N>`) — the user approved that
538
548
  // rule, so its degradation outranks a mere new-candidate nudge.