@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +11 -1
- package/README.md +26 -39
- package/hooks/session-start.mjs +16 -6
- package/hooks/stop.mjs +34 -50
- package/hooks/submit.mjs +34 -31
- package/lib/checkpoint.mjs +14 -4
- package/lib/config.mjs +37 -10
- package/lib/handoff.mjs +12 -2
- package/lib/statusline.mjs +104 -54
- package/lib/tokens.mjs +2 -16
- package/package.json +1 -1
- package/skills/config/SKILL.md +1 -1
- package/skills/stats/SKILL.md +7 -28
- package/test/checkpoint.test.mjs +2 -2
- package/test/config.test.mjs +39 -0
- package/test/integration.test.mjs +4 -1
- package/test/statusline.test.mjs +116 -6
- package/test/submit.test.mjs +3 -9
- package/test/tokens.test.mjs +2 -40
- package/lib/estimate.mjs +0 -254
- package/test/estimate.test.mjs +0 -262
package/lib/statusline.mjs
CHANGED
|
@@ -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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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.
|
|
146
|
-
//
|
|
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
package/skills/config/SKILL.md
CHANGED
|
@@ -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 —
|
|
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:
|
package/skills/stats/SKILL.md
CHANGED
|
@@ -29,25 +29,13 @@ If the file does not exist, display this and stop:
|
|
|
29
29
|
└─────────────────────────────────────────────────
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
## Step 2 —
|
|
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
|
|
65
|
-
│ /cg:prune
|
|
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
|
|
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`,
|
|
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
|
|
64
|
+
If **any** check has `ok: false`, append this inside the box before the closing `└`:
|
|
86
65
|
|
|
87
66
|
```
|
|
88
67
|
│
|
package/test/checkpoint.test.mjs
CHANGED
|
@@ -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,
|
|
64
|
-
assert.equal(state.recent_estimate_pct,
|
|
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: {
|
|
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) {
|
package/test/statusline.test.mjs
CHANGED
|
@@ -109,14 +109,16 @@ describe("threshold-relative colors", () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
it("approaching threshold: dim label, yellow number", () => {
|
|
112
|
-
|
|
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[
|
|
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
|
-
|
|
119
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/test/submit.test.mjs
CHANGED
|
@@ -135,7 +135,7 @@ describe("token state writing", () => {
|
|
|
135
135
|
assert.ok(state.recommendation.includes("All clear"));
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
it("
|
|
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.
|
|
147
|
-
|
|
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
|
|
package/test/tokens.test.mjs
CHANGED
|
@@ -100,7 +100,7 @@ describe("getTokenUsage", () => {
|
|
|
100
100
|
assert.equal(result.current_tokens, 350); // 200 + 100 + 50
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
it("
|
|
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,
|
|
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(
|