@ricky-stevens/context-guardian 2.1.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +63 -0
  3. package/.github/workflows/ci.yml +66 -0
  4. package/CLAUDE.md +132 -0
  5. package/LICENSE +21 -0
  6. package/README.md +362 -0
  7. package/biome.json +34 -0
  8. package/bun.lock +31 -0
  9. package/hooks/precompact.mjs +73 -0
  10. package/hooks/session-start.mjs +133 -0
  11. package/hooks/stop.mjs +172 -0
  12. package/hooks/submit.mjs +133 -0
  13. package/lib/checkpoint.mjs +258 -0
  14. package/lib/compact-cli.mjs +124 -0
  15. package/lib/compact-output.mjs +350 -0
  16. package/lib/config.mjs +40 -0
  17. package/lib/content.mjs +33 -0
  18. package/lib/diagnostics.mjs +221 -0
  19. package/lib/estimate.mjs +254 -0
  20. package/lib/extract-helpers.mjs +869 -0
  21. package/lib/handoff.mjs +329 -0
  22. package/lib/logger.mjs +34 -0
  23. package/lib/mcp-tools.mjs +200 -0
  24. package/lib/paths.mjs +90 -0
  25. package/lib/stats.mjs +81 -0
  26. package/lib/statusline.mjs +123 -0
  27. package/lib/synthetic-session.mjs +273 -0
  28. package/lib/tokens.mjs +170 -0
  29. package/lib/tool-summary.mjs +399 -0
  30. package/lib/transcript.mjs +939 -0
  31. package/lib/trim.mjs +158 -0
  32. package/package.json +22 -0
  33. package/skills/compact/SKILL.md +20 -0
  34. package/skills/config/SKILL.md +70 -0
  35. package/skills/handoff/SKILL.md +26 -0
  36. package/skills/prune/SKILL.md +20 -0
  37. package/skills/stats/SKILL.md +100 -0
  38. package/sonar-project.properties +12 -0
  39. package/test/checkpoint.test.mjs +171 -0
  40. package/test/compact-cli.test.mjs +230 -0
  41. package/test/compact-output.test.mjs +284 -0
  42. package/test/compaction-e2e.test.mjs +809 -0
  43. package/test/content.test.mjs +86 -0
  44. package/test/diagnostics.test.mjs +188 -0
  45. package/test/edge-cases.test.mjs +543 -0
  46. package/test/estimate.test.mjs +262 -0
  47. package/test/extract-helpers-coverage.test.mjs +333 -0
  48. package/test/extract-helpers.test.mjs +234 -0
  49. package/test/handoff.test.mjs +738 -0
  50. package/test/integration.test.mjs +582 -0
  51. package/test/logger.test.mjs +70 -0
  52. package/test/manual-compaction-test.md +426 -0
  53. package/test/mcp-tools.test.mjs +443 -0
  54. package/test/paths.test.mjs +250 -0
  55. package/test/quick-compaction-test.md +191 -0
  56. package/test/stats.test.mjs +88 -0
  57. package/test/statusline.test.mjs +222 -0
  58. package/test/submit.test.mjs +232 -0
  59. package/test/synthetic-session.test.mjs +600 -0
  60. package/test/tokens.test.mjs +293 -0
  61. package/test/tool-summary.test.mjs +771 -0
  62. package/test/transcript-coverage.test.mjs +369 -0
  63. package/test/transcript.test.mjs +596 -0
  64. package/test/trim.test.mjs +356 -0
package/lib/trim.mjs ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Universal text trimming and classification utilities.
3
+ *
4
+ * These functions implement Context Guardian's "keep-start-and-end" strategy:
5
+ * when content exceeds a size limit, we keep the first N and last N characters,
6
+ * trimming only the middle. This preserves intent (start) and outcome (end).
7
+ *
8
+ * @module trim
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Start+end trim — the universal truncation strategy
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Trim content by keeping the first and last portions, removing the middle.
17
+ * Returns content unchanged if it's within the limit.
18
+ *
19
+ * @param {string} content - The text to potentially trim
20
+ * @param {number} limit - Maximum total character length before trimming
21
+ * @param {number} [keepStart] - Characters to keep from the start (default: limit/2)
22
+ * @param {number} [keepEnd] - Characters to keep from the end (default: limit/2)
23
+ * @returns {string} The original or trimmed content
24
+ */
25
+ export function startEndTrim(content, limit, keepStart, keepEnd) {
26
+ if (!content || content.length <= limit) return content || "";
27
+ const half = Math.floor(limit / 2);
28
+ const start = keepStart ?? half;
29
+ const end = keepEnd ?? half;
30
+ const trimmed = content.length - start - end;
31
+ return (
32
+ content.slice(0, start) +
33
+ `\n[...${trimmed} chars trimmed from middle...]\n` +
34
+ content.slice(-end)
35
+ );
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Error detection for tool results
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Patterns that indicate a tool result is an error, not normal output. */
43
+ const ERROR_PATTERNS = [
44
+ /\berror\b/i,
45
+ /\bfailed\b/i,
46
+ /\bdenied\b/i,
47
+ /\bnot found\b/i,
48
+ /\bexception\b/i,
49
+ /\bdoes not exist\b/i,
50
+ /\bpermission\b/i,
51
+ /\bEACCES\b/,
52
+ /\bENOENT\b/,
53
+ /\btimeout\b/i,
54
+ /exit code [1-9]/i,
55
+ /non-zero exit/i,
56
+ ];
57
+
58
+ /**
59
+ * Check whether a tool result string looks like an error response.
60
+ * Used to decide whether to preserve results that would otherwise be removed.
61
+ *
62
+ * @param {string} content - The tool result text
63
+ * @returns {boolean} True if the content appears to be an error
64
+ */
65
+ export function isErrorResponse(content) {
66
+ if (!content || typeof content !== "string") return false;
67
+ return ERROR_PATTERNS.some((re) => re.test(content));
68
+ }
69
+
70
+ /**
71
+ * Check whether a tool result is a SHORT error response (< 500 chars).
72
+ * Used specifically for re-obtainable tools (Read/Grep/Glob) where we only
73
+ * want to keep actual tool failures, not successful results that happen
74
+ * to contain error-related strings in their content.
75
+ *
76
+ * @param {string} content - The tool result text
77
+ * @returns {boolean} True if it's a short error-like response
78
+ */
79
+ export function isShortErrorResponse(content) {
80
+ if (!content || typeof content !== "string") return false;
81
+ return content.length < 500 && isErrorResponse(content);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Confirmation message detection
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Affirmative zero-information words that can be safely skipped.
90
+ * These are user messages that confirm a direction without adding context.
91
+ * NOT included: "no", "n" (rejections = decisions), bare numbers (selections).
92
+ */
93
+ const CONFIRMATIONS = new Set([
94
+ "yes",
95
+ "y",
96
+ "ok",
97
+ "okay",
98
+ "sure",
99
+ "go ahead",
100
+ "continue",
101
+ "proceed",
102
+ "do it",
103
+ "correct",
104
+ "right",
105
+ "exactly",
106
+ "thanks",
107
+ "thank you",
108
+ "yep",
109
+ "yea",
110
+ "yeah",
111
+ "sounds good",
112
+ "go for it",
113
+ "please",
114
+ "agreed",
115
+ "lgtm",
116
+ "ship it",
117
+ ]);
118
+
119
+ /**
120
+ * Check whether a user message is a short affirmative confirmation
121
+ * that adds no meaningful context to the conversation.
122
+ *
123
+ * @param {string} text - The trimmed user message text
124
+ * @returns {boolean} True if the message is a skippable confirmation
125
+ */
126
+ export function isAffirmativeConfirmation(text) {
127
+ if (!text || typeof text !== "string") return false;
128
+ const normalised = text
129
+ .trim()
130
+ .toLowerCase()
131
+ .replace(/[.!,]+$/, "");
132
+ return CONFIRMATIONS.has(normalised);
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Structured injection detection
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Check whether a user message is a known system injection (checkpoint restore,
141
+ * skill injection, etc.) rather than actual user input.
142
+ * Only matches specific known patterns — never drops content based on size alone.
143
+ *
144
+ * @param {string} text - The user message text
145
+ * @returns {boolean} True if the message is a known system injection
146
+ */
147
+ export function isSystemInjection(text) {
148
+ if (!text) return false;
149
+ if (text.startsWith("# Context Checkpoint (") && text.includes("> Created:"))
150
+ return true;
151
+ if (text.includes("<prior_conversation_history>")) return true;
152
+ if (text.includes("SKILL.md") && text.includes("plugin")) return true;
153
+ // Skill invocation injections from Claude Code
154
+ if (text.includes("<command-message>")) return true;
155
+ if (text.includes("<command-name>")) return true;
156
+ if (text.includes("Base directory for this skill:")) return true;
157
+ return false;
158
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@ricky-stevens/context-guardian",
3
+ "version": "2.1.0",
4
+ "description": "Automatic context window monitoring and smart compaction for Claude Code",
5
+ "author": "Ricky Stevens",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/Ricky-Stevens/context-guardian"
10
+ },
11
+ "type": "module",
12
+ "scripts": {
13
+ "test": "node --test test/*.test.mjs",
14
+ "lint": "biome check hooks/ lib/"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@biomejs/biome": "^2.4.10"
21
+ }
22
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: compact
3
+ description: Run Smart Compact — extract full conversation history, strip tool calls and noise
4
+ context: inline
5
+ allowed-tools: Bash
6
+ ---
7
+
8
+ # Context Guardian — Smart Compact
9
+
10
+ Run this command:
11
+
12
+ ```
13
+ node ${CLAUDE_PLUGIN_ROOT}/lib/compact-cli.mjs smart ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_DATA}
14
+ ```
15
+
16
+ The output is JSON. If `success` is `true`, display the `statsBlock` value verbatim — it is a pre-formatted box. Then on the next line, display the `resumeInstruction` value verbatim (it is already bold-formatted markdown). Do not add any extra text.
17
+
18
+ If `success` is `false`, display the `error` value.
19
+
20
+ $ARGUMENTS
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: config
3
+ description: View or update Context Guardian configuration (threshold, max_tokens)
4
+ context: inline
5
+ disable-model-invocation: true
6
+ allowed-tools: Read, Edit, Bash
7
+ ---
8
+
9
+ # Context Guardian Config
10
+
11
+ Manage the configuration file at `${CLAUDE_PLUGIN_DATA}/config.json`.
12
+ If `${CLAUDE_PLUGIN_DATA}` is empty, use `~/.claude/cg/config.json`.
13
+
14
+ ## No arguments — show current config
15
+
16
+ If `$ARGUMENTS` is empty, read these files:
17
+
18
+ 1. `${CLAUDE_PLUGIN_DATA}/config.json` (may not exist — defaults: threshold 0.35, max_tokens 200000)
19
+ 2. `${CLAUDE_PLUGIN_DATA}/state-${CLAUDE_SESSION_ID}.json` (may not exist)
20
+
21
+ If the state file exists and has a `model` field, display:
22
+
23
+ ```
24
+ ┌─────────────────────────────────────────────────
25
+ │ Context Guardian Config
26
+
27
+ │ threshold: {threshold} (trigger warning at {threshold_display}% usage, or threshold × 100 if threshold_display is missing)
28
+ │ max_tokens: {max_tokens formatted with commas} (config default)
29
+ │ detected model: {model from state file}
30
+ │ detected limit: {max_tokens from state file, formatted with commas} tokens
31
+
32
+ │ Config file: {path to config.json}
33
+
34
+ │ Usage:
35
+ │ /cg:config threshold 0.50
36
+ │ /cg:config max_tokens 1000000
37
+ │ /cg:config reset
38
+
39
+ └─────────────────────────────────────────────────
40
+ ```
41
+
42
+ If the state file doesn't exist or has no model field, omit the "detected" lines and show:
43
+
44
+ ```
45
+ │ max_tokens: {max_tokens formatted with commas} (will auto-detect after first response)
46
+ ```
47
+
48
+ Output ONLY the box. No extra commentary.
49
+
50
+ ## With arguments — update config
51
+
52
+ Parse `$ARGUMENTS` as `<key> <value>`.
53
+
54
+ **threshold**: Must be 0.01–0.99 after normalization. Normalize the user's input:
55
+ - Whole number like "50" → divide by 100 → 0.50
56
+ - Percentage like "50%" → strip the %, divide by 100 → 0.50
57
+ - Decimal like "0.50" or ".5" → use as-is → 0.50
58
+ - If the result is < 0.01 or > 0.99, show an error: "Threshold must be between 1% and 99%."
59
+
60
+ **max_tokens**: Must be a positive integer.
61
+
62
+ **reset**: No value needed. Write `{"threshold": 0.35, "max_tokens": 200000}` to the config file.
63
+
64
+ For threshold/max_tokens: Read the existing config (or use defaults if missing), update the key, write back with `JSON.stringify(cfg, null, 2)`.
65
+
66
+ After updating, read back the file and display the config box with a confirmation line: "Config updated. Changes take effect on the next message."
67
+
68
+ If the key is unrecognized, show the Usage section from the box above.
69
+
70
+ $ARGUMENTS
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: handoff
3
+ description: Save session context to a handoff file for cross-session continuity
4
+ context: inline
5
+ allowed-tools: Bash
6
+ ---
7
+
8
+ # Context Guardian — Session Handoff
9
+
10
+ If `$ARGUMENTS` is not empty, pass it as a quoted label argument at the end of the command. If empty, omit it.
11
+
12
+ With label:
13
+ ```
14
+ node ${CLAUDE_PLUGIN_ROOT}/lib/compact-cli.mjs handoff ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_DATA} "the label text"
15
+ ```
16
+
17
+ Without label:
18
+ ```
19
+ node ${CLAUDE_PLUGIN_ROOT}/lib/compact-cli.mjs handoff ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_DATA}
20
+ ```
21
+
22
+ The output is JSON. If `success` is `true`, display the `statsBlock` value verbatim — it is a pre-formatted box. Then on the next line, display the `resumeInstruction` value verbatim (it is already bold-formatted markdown). Do not add any extra text.
23
+
24
+ If `success` is `false`, display the `error` value.
25
+
26
+ $ARGUMENTS
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: prune
3
+ description: Run Keep Recent — drop oldest messages, keep last 10 exchanges
4
+ context: inline
5
+ allowed-tools: Bash
6
+ ---
7
+
8
+ # Context Guardian — Prune (Keep Recent)
9
+
10
+ Run this command:
11
+
12
+ ```
13
+ node ${CLAUDE_PLUGIN_ROOT}/lib/compact-cli.mjs recent ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_DATA}
14
+ ```
15
+
16
+ The output is JSON. If `success` is `true`, display the `statsBlock` value verbatim — it is a pre-formatted box. Then on the next line, display the `resumeInstruction` value verbatim (it is already bold-formatted markdown). Do not add any extra text.
17
+
18
+ If `success` is `false`, display the `error` value.
19
+
20
+ $ARGUMENTS
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: stats
3
+ description: Show current context window usage, threshold, and compaction recommendation
4
+ context: inline
5
+ disable-model-invocation: true
6
+ allowed-tools: Read, Bash
7
+ ---
8
+
9
+ # Context Guardian Stats
10
+
11
+ Read the session-scoped state file and display the status box. Follow these steps exactly.
12
+
13
+ ## Step 1 — Read the state file
14
+
15
+ Read the file at `${CLAUDE_PLUGIN_DATA}/state-${CLAUDE_SESSION_ID}.json`.
16
+
17
+ If `${CLAUDE_PLUGIN_DATA}` is empty, use `~/.claude/cg/` instead.
18
+
19
+ If the file does not exist, display this and stop:
20
+
21
+ ```
22
+ ┌─────────────────────────────────────────────────
23
+ │ Context Guardian Stats
24
+
25
+ │ No data for this session.
26
+ │ Send a non-slash-command message first so the
27
+ │ submit hook can capture token counts.
28
+
29
+ └─────────────────────────────────────────────────
30
+ ```
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
44
+
45
+ All values come directly from the JSON — use them as-is. Do NOT compute any values yourself.
46
+
47
+ - `pct_display` — already a string like "2.5"
48
+ - `threshold_display` — already a number like 35
49
+ - `remaining_to_alert` — already computed (threshold minus current, rounded)
50
+ - `smart_estimate_pct` and `recent_estimate_pct` — already computed
51
+
52
+ ```
53
+ ┌─────────────────────────────────────────────────
54
+ │ Context Guardian Stats
55
+
56
+ │ Current usage: {current_tokens with commas} / {max_tokens with commas} tokens ({pct_display}%)
57
+ │ Session size: {(payload_bytes + baseline_overhead × 4) ÷ 1048576, to 1 decimal, minimum 0.1}MB / 20MB
58
+ │ Threshold: {threshold_display}% ({remaining_to_alert}% remaining to alert)
59
+ │ Data source: {source: "real" → "real counts", "estimated" → "estimated"}
60
+
61
+ │ Model: {model} / {max_tokens with commas} tokens
62
+ │ Last updated: {computed from Step 2}
63
+
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
68
+
69
+ └─────────────────────────────────────────────────
70
+ ```
71
+
72
+ ## Step 4 — Run diagnostics (optional)
73
+
74
+ Run: `node ${CLAUDE_PLUGIN_ROOT}/lib/diagnostics.mjs ${CLAUDE_SESSION_ID} ${CLAUDE_PLUGIN_ROOT} ${CLAUDE_PLUGIN_DATA}`
75
+
76
+ If the command fails or returns invalid JSON, omit the Health section entirely.
77
+
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
+ ```
84
+
85
+ If **any** check has `ok: false`, append this instead:
86
+
87
+ ```
88
+
89
+ │ Health: {count} issue(s) detected
90
+ │ ✗ {check.name}: {check.detail}
91
+ │ ✗ {check.name}: {check.detail}
92
+ ```
93
+
94
+ List only the failed checks. Each on its own `│ ✗` line.
95
+
96
+ ## Output
97
+
98
+ Output the box and nothing else. No extra text, explanation, or commentary beyond what the steps above specify.
99
+
100
+ $ARGUMENTS
@@ -0,0 +1,12 @@
1
+ sonar.projectKey=Ricky-Stevens_context-guardian
2
+ sonar.organization=ricky-stevens
3
+ sonar.projectName=Context Guardian
4
+
5
+ sonar.sources=lib,hooks
6
+ sonar.tests=test
7
+ sonar.sourceEncoding=UTF-8
8
+
9
+ sonar.javascript.lcov.reportPaths=coverage/lcov.info
10
+
11
+ sonar.coverage.exclusions=test/**,skills/**,hooks/**
12
+ sonar.cpd.exclusions=test/**
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, before, describe, it } from "node:test";
6
+
7
+ // DATA_DIR is computed at module load time from process.env.CLAUDE_PLUGIN_DATA,
8
+ // so we must set the env var BEFORE importing checkpoint.mjs.
9
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-checkpoint-test-"));
10
+ process.env.CLAUDE_PLUGIN_DATA = tmpDir;
11
+
12
+ const { writeCompactionState } = await import("../lib/checkpoint.mjs");
13
+
14
+ function stateFilePath(sessionId) {
15
+ return path.join(tmpDir, `state-${sessionId}.json`);
16
+ }
17
+
18
+ function readState(sessionId) {
19
+ return JSON.parse(fs.readFileSync(stateFilePath(sessionId), "utf-8"));
20
+ }
21
+
22
+ // Clean up state files between tests (but keep the tmpDir)
23
+ afterEach(() => {
24
+ for (const f of fs.readdirSync(tmpDir)) {
25
+ if (f.startsWith("state-")) {
26
+ fs.unlinkSync(path.join(tmpDir, f));
27
+ }
28
+ }
29
+ // Also remove config.json if it was written by a test
30
+ const configPath = path.join(tmpDir, "config.json");
31
+ if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
32
+ });
33
+
34
+ // Clean up the tmpDir after all tests
35
+ before(() => {
36
+ process.on("exit", () => {
37
+ fs.rmSync(tmpDir, { recursive: true, force: true });
38
+ });
39
+ });
40
+
41
+ describe("writeCompactionState", () => {
42
+ it("writes state file with correct computed fields", () => {
43
+ writeCompactionState(
44
+ "sess1",
45
+ "/tmp/transcript.jsonl",
46
+ 50000,
47
+ 200000,
48
+ "Smart Compact",
49
+ );
50
+
51
+ const state = readState("sess1");
52
+ assert.equal(state.current_tokens, 50000);
53
+ assert.equal(state.max_tokens, 200000);
54
+ assert.equal(state.pct, 0.25);
55
+ assert.equal(state.pct_display, "25.0");
56
+ // Default threshold is 0.35: headroom = max(0, round(200000 * 0.35 - 50000)) = 20000
57
+ assert.equal(state.headroom, 20000);
58
+ assert.equal(state.source, "estimated");
59
+ assert.equal(state.recommendation, "Smart Compact");
60
+ assert.equal(state.transcript_path, "/tmp/transcript.jsonl");
61
+ assert.equal(state.session_id, "sess1");
62
+ assert.equal(state.model, "unknown");
63
+ assert.equal(state.smart_estimate_pct, 0);
64
+ assert.equal(state.recent_estimate_pct, 0);
65
+ assert.equal(typeof state.ts, "number");
66
+ });
67
+
68
+ it("writes threshold and threshold_display from loaded config", () => {
69
+ // loadConfig() caches on first call; since no config.json existed at
70
+ // import time, the default threshold (0.35) is used for all tests.
71
+ writeCompactionState("sess2", "/tmp/t.jsonl", 60000, 200000, "Keep Recent");
72
+
73
+ const state = readState("sess2");
74
+ assert.equal(state.threshold, 0.35);
75
+ assert.equal(state.threshold_display, 35);
76
+ // headroom = max(0, round(200000 * 0.35 - 60000)) = 10000
77
+ assert.equal(state.headroom, 10000);
78
+ });
79
+
80
+ it("carries forward baseline_overhead from existing state file", () => {
81
+ fs.writeFileSync(
82
+ stateFilePath("sess4"),
83
+ JSON.stringify({ baseline_overhead: 42000, current_tokens: 100000 }),
84
+ );
85
+
86
+ writeCompactionState(
87
+ "sess4",
88
+ "/tmp/t.jsonl",
89
+ 30000,
90
+ 200000,
91
+ "Smart Compact",
92
+ );
93
+
94
+ const state = readState("sess4");
95
+ assert.equal(state.baseline_overhead, 42000);
96
+ });
97
+
98
+ it("defaults baseline_overhead to 0 when no existing state", () => {
99
+ writeCompactionState(
100
+ "sess5",
101
+ "/tmp/t.jsonl",
102
+ 30000,
103
+ 200000,
104
+ "Smart Compact",
105
+ );
106
+
107
+ const state = readState("sess5");
108
+ assert.equal(state.baseline_overhead, 0);
109
+ });
110
+
111
+ it("defaults baseline_overhead to 0 when existing state lacks the field", () => {
112
+ fs.writeFileSync(
113
+ stateFilePath("sess6"),
114
+ JSON.stringify({ current_tokens: 100000 }),
115
+ );
116
+
117
+ writeCompactionState(
118
+ "sess6",
119
+ "/tmp/t.jsonl",
120
+ 30000,
121
+ 200000,
122
+ "Smart Compact",
123
+ );
124
+
125
+ const state = readState("sess6");
126
+ assert.equal(state.baseline_overhead, 0);
127
+ });
128
+
129
+ it("handles corrupt existing state file gracefully", () => {
130
+ fs.writeFileSync(stateFilePath("sess7"), "not json at all{{{");
131
+
132
+ // Inner try/catch handles corrupt JSON, falls back to baseline_overhead=0
133
+ writeCompactionState(
134
+ "sess7",
135
+ "/tmp/t.jsonl",
136
+ 30000,
137
+ 200000,
138
+ "Smart Compact",
139
+ );
140
+
141
+ const state = readState("sess7");
142
+ assert.equal(state.current_tokens, 30000);
143
+ assert.equal(state.baseline_overhead, 0);
144
+ });
145
+
146
+ it("has source set to estimated", () => {
147
+ writeCompactionState(
148
+ "sess8",
149
+ "/tmp/t.jsonl",
150
+ 10000,
151
+ 200000,
152
+ "Smart Compact",
153
+ );
154
+
155
+ const state = readState("sess8");
156
+ assert.equal(state.source, "estimated");
157
+ });
158
+
159
+ it("handles max=0 without throwing", () => {
160
+ // tokens/max = Infinity, but JSON.stringify handles Infinity → null
161
+ writeCompactionState("sess9", "/tmp/t.jsonl", 10000, 0, "Smart Compact");
162
+
163
+ const state = readState("sess9");
164
+ assert.equal(state.current_tokens, 10000);
165
+ assert.equal(state.max_tokens, 0);
166
+ // pct: 10000/0 = Infinity, JSON serializes to null
167
+ assert.equal(state.pct, null);
168
+ // headroom: Math.max(0, Math.round(0 * 0.35 - 10000)) = 0
169
+ assert.equal(state.headroom, 0);
170
+ });
171
+ });