claude-token-saver 3.7.0 → 3.8.0
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 +23 -0
- package/README.md +23 -0
- package/bin/cli.js +11 -0
- package/package.json +1 -1
- package/src/brief.js +19 -0
- package/src/commands/compact-window.js +90 -0
- package/src/compact-window.js +192 -0
- package/src/harness.js +10 -0
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,9 @@ Also update `statusLine.command` in `~/.claude/settings.json` to `claude-token-s
|
|
|
243
263
|
|
|
244
264
|
## Release notes
|
|
245
265
|
|
|
266
|
+
### v3.8.0 (2026-07-31)
|
|
267
|
+
- **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.
|
|
268
|
+
|
|
246
269
|
### v3.7.0 (2026-07-29)
|
|
247
270
|
- **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
271
|
|
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,9 @@ npm uninstall -g claude-cache-monitor && npm i -g claude-token-saver
|
|
|
200
220
|
|
|
201
221
|
## 릴리스 노트
|
|
202
222
|
|
|
223
|
+
### v3.8.0 (2026-07-31)
|
|
224
|
+
- **`compact-window` 추가 — 1M 컨텍스트에서 자동 압축 지점이 방치되던 문제** — Claude Code는 `min(autoCompactWindow, 모델 최대 창)` 근처에서 압축합니다. 1M 창을 쓰면 이 값을 안 잡는 한 80만 토큰까지 커진 뒤에야 압축이 걸리고, 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 이제 1M 모델인데 미설정이거나 40만 초과면 statusline `🅷⚠ compact-window?` + 세션 브리핑으로 알리고, `compact-window set --global|--project`로 40만을 고정합니다. 200k 컨텍스트는 설정이 영향을 주지 않으므로 경고 대상에서 제외합니다.
|
|
225
|
+
|
|
203
226
|
### v3.7.0 (2026-07-29)
|
|
204
227
|
- **위임 룰이 없는 서브에이전트를 가리키던 문제 수정** — 생성되는 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
228
|
|
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
package/src/brief.js
CHANGED
|
@@ -180,6 +180,25 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
180
180
|
s.briefed = [...briefed];
|
|
181
181
|
} catch { /* caches unreadable — ctx briefing above still applies */ }
|
|
182
182
|
|
|
183
|
+
// ── auto-compact window misconfigured on a 1M model (once per session) ──
|
|
184
|
+
// Config defect, not a usage trend: on a 1M window compaction only fires
|
|
185
|
+
// past ~800k, so every request until then re-bills a context the session
|
|
186
|
+
// never needed. 200k sessions are exempt (their window is already <= 200k).
|
|
187
|
+
try {
|
|
188
|
+
const cw = await import('./compact-window.js');
|
|
189
|
+
const st = cw.compactWindowStatus({ root: process.cwd() });
|
|
190
|
+
if (!st.ok) {
|
|
191
|
+
const sig = `compact-window|${st.reason}`;
|
|
192
|
+
const briefed = new Set(s.briefed || []);
|
|
193
|
+
if (!briefed.has(sig)) {
|
|
194
|
+
briefed.add(sig);
|
|
195
|
+
s.briefed = [...briefed];
|
|
196
|
+
const now = st.window ? `현재 ${fmtK(st.window)}` : '현재 미설정';
|
|
197
|
+
items.push(`1M 컨텍스트 모델(${st.model})인데 autoCompactWindow가 ${now}입니다 — 자동 압축이 80만 토큰 근처에서야 걸려 그전까지 모든 요청이 전체 컨텍스트를 재과금합니다. 40만으로 고정하면 1M 창은 그대로 두고 압축 시점만 앞당깁니다. 등록: claude-token-saver compact-window set --global|--project (적용 범위는 사용자에게 확인) / 끄기: compact-window off`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch { /* settings unreadable — other briefings above still apply */ }
|
|
201
|
+
|
|
183
202
|
s.ts = now;
|
|
184
203
|
state.sessions[sessionId] = s;
|
|
185
204
|
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.
|