@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.
- package/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +63 -0
- package/.github/workflows/ci.yml +66 -0
- package/CLAUDE.md +132 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/biome.json +34 -0
- package/bun.lock +31 -0
- package/hooks/precompact.mjs +73 -0
- package/hooks/session-start.mjs +133 -0
- package/hooks/stop.mjs +172 -0
- package/hooks/submit.mjs +133 -0
- package/lib/checkpoint.mjs +258 -0
- package/lib/compact-cli.mjs +124 -0
- package/lib/compact-output.mjs +350 -0
- package/lib/config.mjs +40 -0
- package/lib/content.mjs +33 -0
- package/lib/diagnostics.mjs +221 -0
- package/lib/estimate.mjs +254 -0
- package/lib/extract-helpers.mjs +869 -0
- package/lib/handoff.mjs +329 -0
- package/lib/logger.mjs +34 -0
- package/lib/mcp-tools.mjs +200 -0
- package/lib/paths.mjs +90 -0
- package/lib/stats.mjs +81 -0
- package/lib/statusline.mjs +123 -0
- package/lib/synthetic-session.mjs +273 -0
- package/lib/tokens.mjs +170 -0
- package/lib/tool-summary.mjs +399 -0
- package/lib/transcript.mjs +939 -0
- package/lib/trim.mjs +158 -0
- package/package.json +22 -0
- package/skills/compact/SKILL.md +20 -0
- package/skills/config/SKILL.md +70 -0
- package/skills/handoff/SKILL.md +26 -0
- package/skills/prune/SKILL.md +20 -0
- package/skills/stats/SKILL.md +100 -0
- package/sonar-project.properties +12 -0
- package/test/checkpoint.test.mjs +171 -0
- package/test/compact-cli.test.mjs +230 -0
- package/test/compact-output.test.mjs +284 -0
- package/test/compaction-e2e.test.mjs +809 -0
- package/test/content.test.mjs +86 -0
- package/test/diagnostics.test.mjs +188 -0
- package/test/edge-cases.test.mjs +543 -0
- package/test/estimate.test.mjs +262 -0
- package/test/extract-helpers-coverage.test.mjs +333 -0
- package/test/extract-helpers.test.mjs +234 -0
- package/test/handoff.test.mjs +738 -0
- package/test/integration.test.mjs +582 -0
- package/test/logger.test.mjs +70 -0
- package/test/manual-compaction-test.md +426 -0
- package/test/mcp-tools.test.mjs +443 -0
- package/test/paths.test.mjs +250 -0
- package/test/quick-compaction-test.md +191 -0
- package/test/stats.test.mjs +88 -0
- package/test/statusline.test.mjs +222 -0
- package/test/submit.test.mjs +232 -0
- package/test/synthetic-session.test.mjs +600 -0
- package/test/tokens.test.mjs +293 -0
- package/test/tool-summary.test.mjs +771 -0
- package/test/transcript-coverage.test.mjs +369 -0
- package/test/transcript.test.mjs +596 -0
- 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
|
+
});
|