@ricky-stevens/context-guardian 2.1.0 → 2.2.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.
@@ -25,6 +25,7 @@ process.stdin.on("error", () => {
25
25
  process.stdin.on("end", () => {
26
26
  try {
27
27
  const data = JSON.parse(raw);
28
+ persistSessionMetadata(data);
28
29
  process.stdout.write(render(data));
29
30
  } catch {
30
31
  process.stdout.write("Context: --");
@@ -32,38 +33,118 @@ process.stdin.on("end", () => {
32
33
  });
33
34
 
34
35
  /**
35
- * Render the statusline output from Claude Code's session data.
36
- *
37
- * Claude Code pipes JSON with this structure:
38
- * context_window: { used_percentage, remaining_percentage, total_input_tokens, total_output_tokens }
39
- * model: { id, display_name }
36
+ * Persist authoritative session metadata from Claude Code's statusline JSON
37
+ * into the per-session state file. The statusline is the only CG component
38
+ * that receives these values directly from CC. Hooks read them from here.
40
39
  */
41
- function render(data) {
42
- const pctRaw = data.context_window?.used_percentage;
43
- if (pctRaw == null) {
44
- const dim = "\x1b[2m";
45
- const reset = "\x1b[0m";
46
- return `${dim}Context usage: --${reset}`;
47
- }
40
+ function persistSessionMetadata(data) {
41
+ const size = data.context_window?.context_window_size;
42
+ const modelId = data.model?.id;
43
+ const sessionId = data.session_id;
44
+ if (!sessionId || (typeof size !== "number" && !modelId)) return;
45
+ try {
46
+ const dir = path.join(os.homedir(), ".claude", "cg");
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ const filePath = path.join(dir, `state-${sessionId}.json`);
48
49
 
49
- const pct = Math.round(pctRaw);
50
+ // Read-modify-write: merge CC-provided values into existing state.
51
+ // Skip the write if nothing changed — minimises race window with hooks.
52
+ let state = {};
53
+ try {
54
+ state = JSON.parse(fs.readFileSync(filePath, "utf8"));
55
+ } catch {}
50
56
 
51
- // Read threshold from config if available, fallback to 35%
57
+ let changed = false;
58
+ if (
59
+ typeof size === "number" &&
60
+ size > 0 &&
61
+ state.context_window_size !== size
62
+ ) {
63
+ state.context_window_size = size;
64
+ changed = true;
65
+ }
66
+ if (modelId && state.cc_model_id !== modelId) {
67
+ state.cc_model_id = modelId;
68
+ changed = true;
69
+ }
70
+ if (!changed) return;
71
+
72
+ const rand = Math.random().toString(36).slice(2, 10);
73
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.${rand}.tmp`;
74
+ fs.writeFileSync(tmp, JSON.stringify(state));
75
+ fs.renameSync(tmp, filePath);
76
+ } catch {}
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Threshold resolution — adaptive based on context window size, with
81
+ // user-configured override. Same formula as computeAdaptiveThreshold in
82
+ // config.mjs: 55% at 200K, 30% at 1M, clamped [25%, 55%].
83
+ // ---------------------------------------------------------------------------
84
+ function resolveThreshold(data) {
52
85
  const dataDir =
53
86
  process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".claude", "cg");
54
- let threshold = 35;
55
87
  try {
56
88
  const configPath = path.join(dataDir, "config.json");
57
89
  if (fs.existsSync(configPath)) {
58
90
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
59
- if (cfg.threshold) threshold = Math.round(cfg.threshold * 100);
91
+ if ("threshold" in cfg) return Math.round(cfg.threshold * 100);
60
92
  }
61
93
  } catch {}
94
+ const windowSize = data.context_window?.context_window_size || 200000;
95
+ const adaptive = Math.min(
96
+ 0.55,
97
+ Math.max(0.25, 0.55 - ((windowSize - 200000) * 0.25) / 800000),
98
+ );
99
+ return Math.round(adaptive * 100);
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Session size — reads the most recent state file from the data dir to get
104
+ // payload_bytes and baseline_overhead for the ~20MB API limit display.
105
+ // ---------------------------------------------------------------------------
106
+ function readSessionSize(dataDir) {
107
+ const stateFiles = fs
108
+ .readdirSync(dataDir)
109
+ .filter((f) => f.startsWith("state-") && f.endsWith(".json"));
110
+ if (stateFiles.length === 0) return 0;
111
+
112
+ let newest = stateFiles[0];
113
+ let newestMtime = 0;
114
+ for (const f of stateFiles) {
115
+ const mt = fs.statSync(path.join(dataDir, f)).mtimeMs;
116
+ if (mt > newestMtime) {
117
+ newestMtime = mt;
118
+ newest = f;
119
+ }
120
+ }
121
+ const state = JSON.parse(fs.readFileSync(path.join(dataDir, newest), "utf8"));
122
+ const overheadBytes = (state.baseline_overhead || 0) * 4;
123
+ return (state.payload_bytes || 0) + overheadBytes;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Color-coded session size string.
128
+ // ---------------------------------------------------------------------------
129
+ function formatSessionSize(totalBytes, dim, reset) {
130
+ if (totalBytes <= 0) return `${dim}--${reset}`;
131
+ const mb = Math.max(0.1, totalBytes / (1024 * 1024)).toFixed(1);
132
+ if (mb >= 15) return `\x1b[1;31mSession size: ${mb}/20MB${reset}`;
133
+ const numColor = mb < 10 ? "\x1b[32m" : "\x1b[33m";
134
+ return `${dim}Session size:${reset} ${numColor}${mb}${dim}/20MB${reset}`;
135
+ }
62
136
 
63
- // Color strategy:
64
- // - Green: labels dim/grey, only numbers colored green
65
- // - Yellow: labels dim/grey, only numbers colored yellow
66
- // - Red: entire label+number is bold red (maximum visibility)
137
+ /**
138
+ * Render the statusline output from Claude Code's session data.
139
+ */
140
+ function render(data) {
141
+ const pctRaw = data.context_window?.used_percentage;
142
+ if (pctRaw == null) {
143
+ return "\x1b[2mContext usage: --\x1b[0m";
144
+ }
145
+
146
+ const pct = Math.round(pctRaw);
147
+ const threshold = resolveThreshold(data);
67
148
  const reset = "\x1b[0m";
68
149
  const dim = "\x1b[2m";
69
150
 
@@ -75,42 +156,11 @@ function render(data) {
75
156
  contextStr = `${dim}Context usage:${reset} ${numColor}${pct}%${reset}`;
76
157
  }
77
158
 
78
- // Session size — proxy for the ~20MB API request limit (separate from token limit).
79
- // Read from the most recent state file written by submit/stop hooks.
159
+ const dataDir =
160
+ process.env.CLAUDE_PLUGIN_DATA || path.join(os.homedir(), ".claude", "cg");
80
161
  let sessionStr = `${dim}--${reset}`;
81
162
  try {
82
- const stateFiles = fs
83
- .readdirSync(dataDir)
84
- .filter((f) => f.startsWith("state-") && f.endsWith(".json"));
85
- if (stateFiles.length > 0) {
86
- // Pick the most recently modified state file
87
- let newest = stateFiles[0];
88
- let newestMtime = 0;
89
- for (const f of stateFiles) {
90
- const mt = fs.statSync(path.join(dataDir, f)).mtimeMs;
91
- if (mt > newestMtime) {
92
- newestMtime = mt;
93
- newest = f;
94
- }
95
- }
96
- const state = JSON.parse(
97
- fs.readFileSync(path.join(dataDir, newest), "utf8"),
98
- );
99
- // Total payload = transcript file + system overhead (prompts, tools, CLAUDE.md).
100
- // The transcript JSONL only contains conversation messages, not the full
101
- // API request. baseline_overhead (tokens) × 4 ≈ system overhead in bytes.
102
- const overheadBytes = (state.baseline_overhead || 0) * 4;
103
- const totalBytes = (state.payload_bytes || 0) + overheadBytes;
104
- if (totalBytes > 0) {
105
- const mb = Math.max(0.1, totalBytes / (1024 * 1024)).toFixed(1);
106
- if (mb >= 15) {
107
- sessionStr = `\x1b[1;31mSession size: ${mb}/20MB${reset}`;
108
- } else {
109
- const numColor = mb < 10 ? "\x1b[32m" : "\x1b[33m";
110
- sessionStr = `${dim}Session size:${reset} ${numColor}${mb}${dim}/20MB${reset}`;
111
- }
112
- }
113
- }
163
+ sessionStr = formatSessionSize(readSessionSize(dataDir), dim, reset);
114
164
  } catch {}
115
165
 
116
166
  const untilAlert = Math.max(0, threshold - pct);
package/lib/tokens.mjs CHANGED
@@ -59,23 +59,9 @@ function _findUsage(lines) {
59
59
  const cacheRead = usage.cache_read_input_tokens || 0;
60
60
  const output = usage.output_tokens || 0;
61
61
 
62
- // Detect max_tokens from model name in the same message.
63
- // Only Opus 4.6+ has 1M tokens. Format: "claude-opus-4-6"
64
- const model = (obj.message?.model || "").toLowerCase();
65
- let max_tokens = 200000; // default for all Sonnet/Haiku/older Opus
66
- const opusMatch = model.match(/opus[- ]?(\d+)[- .]?(\d+)?/);
67
- if (opusMatch) {
68
- const major = Number.parseInt(opusMatch[1], 10);
69
- const minor = Number.parseInt(opusMatch[2] || "0", 10);
70
- if (major > 4 || (major === 4 && minor >= 6)) {
71
- max_tokens = 1000000;
72
- }
73
- }
74
-
75
62
  return {
76
63
  current_tokens: inputTokens + cacheCreate + cacheRead,
77
64
  output_tokens: output,
78
- max_tokens,
79
65
  model: obj.message?.model || "unknown",
80
66
  };
81
67
  }
@@ -142,8 +128,8 @@ export function estimateTokens(transcriptPath) {
142
128
  // Session overhead estimation — tokens from system prompt, tool definitions,
143
129
  // memory files, and skills that survive compaction unchanged.
144
130
  // Uses transcript file size / 4 to estimate conversation tokens, then
145
- // subtracts from real token count. Both estimate.mjs and checkpoint.mjs
146
- // use this for consistent predictions.
131
+ // subtracts from real token count. Used by checkpoint.mjs and handoff.mjs
132
+ // for post-compaction stats.
147
133
  // ---------------------------------------------------------------------------
148
134
  export function estimateOverhead(
149
135
  currentTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricky-stevens/context-guardian",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Automatic context window monitoring and smart compaction for Claude Code",
5
5
  "author": "Ricky Stevens",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@ If `${CLAUDE_PLUGIN_DATA}` is empty, use `~/.claude/cg/config.json`.
15
15
 
16
16
  If `$ARGUMENTS` is empty, read these files:
17
17
 
18
- 1. `${CLAUDE_PLUGIN_DATA}/config.json` (may not exist — defaults: threshold 0.35, max_tokens 200000)
18
+ 1. `${CLAUDE_PLUGIN_DATA}/config.json` (may not exist — threshold is adaptive based on context window size, max_tokens defaults to 200000)
19
19
  2. `${CLAUDE_PLUGIN_DATA}/state-${CLAUDE_SESSION_ID}.json` (may not exist)
20
20
 
21
21
  If the state file exists and has a `model` field, display:
@@ -29,25 +29,13 @@ If the file does not exist, display this and stop:
29
29
  └─────────────────────────────────────────────────
30
30
  ```
31
31
 
32
- ## Step 2 — Compute "Last updated"
33
-
34
- Run: `echo $(( $(date +%s) - JSON_TS_VALUE / 1000 ))`
35
-
36
- Replace `JSON_TS_VALUE` with the `ts` field from the JSON. The command outputs the age in seconds. Display it as:
37
- - Under 60: "Xs ago"
38
- - 60–3599: "Xm ago"
39
- - 3600+: "Xh ago"
40
-
41
- If the result is greater than 300, append " (stale)".
42
-
43
- ## Step 3 — Display the status box
32
+ ## Step 2 — Display the status box
44
33
 
45
34
  All values come directly from the JSON — use them as-is. Do NOT compute any values yourself.
46
35
 
47
36
  - `pct_display` — already a string like "2.5"
48
37
  - `threshold_display` — already a number like 35
49
38
  - `remaining_to_alert` — already computed (threshold minus current, rounded)
50
- - `smart_estimate_pct` and `recent_estimate_pct` — already computed
51
39
 
52
40
  ```
53
41
  ┌─────────────────────────────────────────────────
@@ -56,33 +44,24 @@ All values come directly from the JSON — use them as-is. Do NOT compute any va
56
44
  │ Current usage: {current_tokens with commas} / {max_tokens with commas} tokens ({pct_display}%)
57
45
  │ Session size: {(payload_bytes + baseline_overhead × 4) ÷ 1048576, to 1 decimal, minimum 0.1}MB / 20MB
58
46
  │ Threshold: {threshold_display}% ({remaining_to_alert}% remaining to alert)
59
- │ Data source: {source: "real" → "real counts", "estimated" → "estimated"}
60
-
61
47
  │ Model: {model} / {max_tokens with commas} tokens
62
- │ Last updated: {computed from Step 2}
63
48
 
64
- │ /cg:compact ~{pct_display}% ~{smart_estimate_pct}%
65
- │ /cg:prune ~{pct_display}% ~{recent_estimate_pct}%
66
-
67
- │ /cg:handoff [name] save session for later
49
+ │ /cg:compact smart compact — strips file reads, system noise
50
+ │ /cg:prune keep last 10 exchanges only
51
+ /cg:handoff [name] save session for later
68
52
 
69
53
  └─────────────────────────────────────────────────
70
54
  ```
71
55
 
72
- ## Step 4 — Run diagnostics (optional)
56
+ ## Step 3 — Run diagnostics
73
57
 
74
58
  Run: `node ${CLAUDE_PLUGIN_ROOT}/lib/diagnostics.mjs ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_ROOT} ${CLAUDE_PLUGIN_DATA}`
75
59
 
76
60
  If the command fails or returns invalid JSON, omit the Health section entirely.
77
61
 
78
- Parse the JSON output. If **all** checks have `ok: true`, append this line inside the box before the closing `└`:
79
-
80
- ```
81
-
82
- │ Health: All checks passed
83
- ```
62
+ Parse the JSON output. If **all** checks have `ok: true`, do NOT add a Health section.
84
63
 
85
- If **any** check has `ok: false`, append this instead:
64
+ If **any** check has `ok: false`, append this inside the box before the closing `└`:
86
65
 
87
66
  ```
88
67
 
@@ -60,8 +60,8 @@ describe("writeCompactionState", () => {
60
60
  assert.equal(state.transcript_path, "/tmp/transcript.jsonl");
61
61
  assert.equal(state.session_id, "sess1");
62
62
  assert.equal(state.model, "unknown");
63
- assert.equal(state.smart_estimate_pct, 0);
64
- assert.equal(state.recent_estimate_pct, 0);
63
+ assert.equal(state.smart_estimate_pct, undefined);
64
+ assert.equal(state.recent_estimate_pct, undefined);
65
65
  assert.equal(typeof state.ts, "number");
66
66
  });
67
67
 
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { computeAdaptiveThreshold } from "../lib/config.mjs";
4
+
5
+ describe("computeAdaptiveThreshold", () => {
6
+ it("returns 0.55 for 200K window", () => {
7
+ assert.equal(computeAdaptiveThreshold(200000), 0.55);
8
+ });
9
+
10
+ it("returns 0.30 for 1M window (lower bound area)", () => {
11
+ const result = computeAdaptiveThreshold(1000000);
12
+ assert.ok(
13
+ result >= 0.25 && result <= 0.31,
14
+ `expected ~0.30, got ${result}`,
15
+ );
16
+ });
17
+
18
+ it("returns intermediate value for 500K window", () => {
19
+ const result = computeAdaptiveThreshold(500000);
20
+ assert.ok(result > 0.3 && result < 0.55, `expected ~0.46, got ${result}`);
21
+ });
22
+
23
+ it("clamps to 0.55 for windows smaller than 200K", () => {
24
+ assert.equal(computeAdaptiveThreshold(100000), 0.55);
25
+ });
26
+
27
+ it("clamps to 0.25 for very large windows", () => {
28
+ assert.equal(computeAdaptiveThreshold(5000000), 0.25);
29
+ });
30
+
31
+ it("scales linearly between 200K and 1M", () => {
32
+ const at200k = computeAdaptiveThreshold(200000);
33
+ const at600k = computeAdaptiveThreshold(600000);
34
+ const at1m = computeAdaptiveThreshold(1000000);
35
+ // Should decrease monotonically
36
+ assert.ok(at200k > at600k, "200K threshold should be higher than 600K");
37
+ assert.ok(at600k > at1m, "600K threshold should be higher than 1M");
38
+ });
39
+ });
@@ -56,7 +56,10 @@ function runHook(input) {
56
56
  input: stdin,
57
57
  encoding: "utf8",
58
58
  timeout: 5000,
59
- env: { ...process.env, CLAUDE_PLUGIN_DATA: dataDir },
59
+ env: {
60
+ ...process.env,
61
+ CLAUDE_PLUGIN_DATA: dataDir,
62
+ },
60
63
  });
61
64
  return stdout ? JSON.parse(stdout) : null;
62
65
  } catch (e) {
@@ -109,14 +109,16 @@ describe("threshold-relative colors", () => {
109
109
  });
110
110
 
111
111
  it("approaching threshold: dim label, yellow number", () => {
112
- const raw = runStatusline({ context_window: { used_percentage: 30 } });
112
+ // Default adaptive threshold for 200K is 55%, yellow starts at 55*0.7=38.5%
113
+ const raw = runStatusline({ context_window: { used_percentage: 45 } });
113
114
  assert.ok(raw.includes("\x1b[2mContext usage:\x1b[0m")); // dim label
114
- assert.ok(raw.includes("\x1b[33m30%")); // yellow number
115
+ assert.ok(raw.includes("\x1b[33m45%")); // yellow number
115
116
  });
116
117
 
117
118
  it("at threshold: bold red on entire label+number", () => {
118
- const raw = runStatusline({ context_window: { used_percentage: 40 } });
119
- assert.ok(raw.includes("\x1b[1;31mContext usage: 40%")); // bold red full
119
+ // Default adaptive threshold for 200K is 55%
120
+ const raw = runStatusline({ context_window: { used_percentage: 60 } });
121
+ assert.ok(raw.includes("\x1b[1;31mContext usage: 60%")); // bold red full
120
122
  });
121
123
 
122
124
  it("colors adjust with custom threshold", () => {
@@ -138,6 +140,26 @@ describe("threshold-relative colors", () => {
138
140
  );
139
141
  assert.ok(redRaw.includes("\x1b[1;31mContext usage: 75%"));
140
142
  });
143
+
144
+ it("adaptive threshold: 1M window uses lower threshold than 200K", () => {
145
+ // 1M adaptive threshold = 30%, so 25% is yellow (above 30*0.7=21%)
146
+ const yellowRaw = runStatusline({
147
+ context_window: { used_percentage: 25, context_window_size: 1000000 },
148
+ });
149
+ assert.ok(yellowRaw.includes("\x1b[33m25%")); // yellow
150
+
151
+ // 35% is red on 1M (above 30% threshold)
152
+ const redRaw = runStatusline({
153
+ context_window: { used_percentage: 35, context_window_size: 1000000 },
154
+ });
155
+ assert.ok(redRaw.includes("\x1b[1;31mContext usage: 35%")); // bold red
156
+
157
+ // Same 35% on 200K is green (below 55*0.7=38.5%)
158
+ const greenRaw = runStatusline({
159
+ context_window: { used_percentage: 35, context_window_size: 200000 },
160
+ });
161
+ assert.ok(greenRaw.includes("\x1b[32m35%")); // green
162
+ });
141
163
  });
142
164
 
143
165
  describe("session size display", () => {
@@ -198,17 +220,105 @@ describe("session size display", () => {
198
220
  });
199
221
  });
200
222
 
223
+ describe("context window size persistence", () => {
224
+ const stateDir = path.join(os.homedir(), ".claude", "cg");
225
+
226
+ it("writes context_window_size into per-session state file", () => {
227
+ const sessionId = `sl-test-${Date.now()}`;
228
+ const stateFile = path.join(stateDir, `state-${sessionId}.json`);
229
+ try {
230
+ runStatusline({
231
+ session_id: sessionId,
232
+ context_window: {
233
+ used_percentage: 10,
234
+ context_window_size: 1000000,
235
+ },
236
+ });
237
+
238
+ assert.ok(fs.existsSync(stateFile), "state file should exist");
239
+ const data = JSON.parse(fs.readFileSync(stateFile, "utf8"));
240
+ assert.equal(data.context_window_size, 1000000);
241
+ } finally {
242
+ try {
243
+ fs.unlinkSync(stateFile);
244
+ } catch {}
245
+ }
246
+ });
247
+
248
+ it("merges context_window_size into existing state file", () => {
249
+ const sessionId = `sl-test-${Date.now()}`;
250
+ const stateFile = path.join(stateDir, `state-${sessionId}.json`);
251
+ try {
252
+ // Pre-populate state file (as a hook would)
253
+ fs.mkdirSync(stateDir, { recursive: true });
254
+ fs.writeFileSync(
255
+ stateFile,
256
+ JSON.stringify({ current_tokens: 5000, max_tokens: 200000 }),
257
+ );
258
+
259
+ runStatusline({
260
+ session_id: sessionId,
261
+ context_window: {
262
+ used_percentage: 10,
263
+ context_window_size: 1000000,
264
+ },
265
+ });
266
+
267
+ const data = JSON.parse(fs.readFileSync(stateFile, "utf8"));
268
+ assert.equal(data.context_window_size, 1000000);
269
+ assert.equal(data.current_tokens, 5000); // preserved
270
+ } finally {
271
+ try {
272
+ fs.unlinkSync(stateFile);
273
+ } catch {}
274
+ }
275
+ });
276
+
277
+ it("does not write when context_window_size is missing", () => {
278
+ const sessionId = `sl-test-nowrite-${Date.now()}`;
279
+ const stateFile = path.join(stateDir, `state-${sessionId}.json`);
280
+ try {
281
+ runStatusline({
282
+ session_id: sessionId,
283
+ context_window: { used_percentage: 10 },
284
+ });
285
+
286
+ assert.equal(fs.existsSync(stateFile), false);
287
+ } finally {
288
+ try {
289
+ fs.unlinkSync(stateFile);
290
+ } catch {}
291
+ }
292
+ });
293
+
294
+ it("does not write when session_id is missing", () => {
295
+ // Without session_id, we can't target a state file
296
+ // Just verify no crash — the render output should still work
297
+ const out = strip(
298
+ runStatusline({
299
+ context_window: {
300
+ used_percentage: 10,
301
+ context_window_size: 1000000,
302
+ },
303
+ }),
304
+ );
305
+ assert.ok(out.includes("10%"));
306
+ });
307
+ });
308
+
201
309
  describe("alert state messaging", () => {
202
310
  it("at threshold shows actionable compaction message", () => {
311
+ // Default adaptive threshold for 200K is 55%
203
312
  const out = strip(
204
- runStatusline({ context_window: { used_percentage: 40 } }),
313
+ runStatusline({ context_window: { used_percentage: 60 } }),
205
314
  );
206
315
  assert.ok(out.includes("compaction recommended"));
207
316
  assert.ok(out.includes("/cg:compact"));
208
317
  });
209
318
 
210
319
  it("at threshold uses bold red for alert text", () => {
211
- const raw = runStatusline({ context_window: { used_percentage: 40 } });
320
+ // Default adaptive threshold for 200K is 55%
321
+ const raw = runStatusline({ context_window: { used_percentage: 60 } });
212
322
  assert.ok(raw.includes("\x1b[1;31mcompaction recommended"));
213
323
  });
214
324
 
@@ -135,7 +135,7 @@ describe("token state writing", () => {
135
135
  assert.ok(state.recommendation.includes("All clear"));
136
136
  });
137
137
 
138
- it("includes savings estimates in state", () => {
138
+ it("does not include savings estimates in state (removed — inaccurate)", () => {
139
139
  writeLine(makeUser("hello"));
140
140
  writeLine(makeAssistant("hi", HIGH_USAGE));
141
141
 
@@ -143,14 +143,8 @@ describe("token state writing", () => {
143
143
 
144
144
  const sf = path.join(dataDir, "state-test-session-1234.json");
145
145
  const state = JSON.parse(fs.readFileSync(sf, "utf8"));
146
- assert.ok(
147
- state.smart_estimate_pct != null,
148
- "smart_estimate_pct should exist",
149
- );
150
- assert.ok(
151
- state.recent_estimate_pct != null,
152
- "recent_estimate_pct should exist",
153
- );
146
+ assert.equal(state.smart_estimate_pct, undefined);
147
+ assert.equal(state.recent_estimate_pct, undefined);
154
148
  });
155
149
  });
156
150
 
@@ -100,7 +100,7 @@ describe("getTokenUsage", () => {
100
100
  assert.equal(result.current_tokens, 350); // 200 + 100 + 50
101
101
  });
102
102
 
103
- it("detects Opus 4.6+ as 1M context", () => {
103
+ it("does not include max_tokens (callers resolve from state file)", () => {
104
104
  writeLine(makeUserMessage("hello"));
105
105
  writeLine(
106
106
  makeAssistantMessage(
@@ -116,48 +116,10 @@ describe("getTokenUsage", () => {
116
116
  );
117
117
 
118
118
  const result = getTokenUsage(transcriptPath);
119
- assert.equal(result.max_tokens, 1000000);
119
+ assert.equal(result.max_tokens, undefined);
120
120
  assert.equal(result.model, "claude-opus-4-6-20260101");
121
121
  });
122
122
 
123
- it("detects Sonnet as 200K context", () => {
124
- writeLine(makeUserMessage("hello"));
125
- writeLine(
126
- makeAssistantMessage(
127
- "hi",
128
- {
129
- input_tokens: 100,
130
- cache_creation_input_tokens: 0,
131
- cache_read_input_tokens: 0,
132
- output_tokens: 10,
133
- },
134
- "claude-sonnet-4-20250514",
135
- ),
136
- );
137
-
138
- const result = getTokenUsage(transcriptPath);
139
- assert.equal(result.max_tokens, 200000);
140
- });
141
-
142
- it("detects future Opus 5.x as 1M context", () => {
143
- writeLine(makeUserMessage("hello"));
144
- writeLine(
145
- makeAssistantMessage(
146
- "hi",
147
- {
148
- input_tokens: 100,
149
- cache_creation_input_tokens: 0,
150
- cache_read_input_tokens: 0,
151
- output_tokens: 10,
152
- },
153
- "claude-opus-5-0",
154
- ),
155
- );
156
-
157
- const result = getTokenUsage(transcriptPath);
158
- assert.equal(result.max_tokens, 1000000);
159
- });
160
-
161
123
  it("handles zero usage values", () => {
162
124
  writeLine(makeUserMessage("hello"));
163
125
  writeLine(