@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/estimate.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast compaction savings estimator.
|
|
3
|
+
*
|
|
4
|
+
* Performs a single-pass byte categorisation of the transcript to estimate
|
|
5
|
+
* how much context each compaction mode would save — WITHOUT running
|
|
6
|
+
* the full extraction pipeline. Used by the statusline state and /cg:stats
|
|
7
|
+
* to show estimated post-compaction percentages.
|
|
8
|
+
*
|
|
9
|
+
* @module estimate
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import { estimateOverhead } from "./tokens.mjs";
|
|
14
|
+
import { readTranscriptLines } from "./transcript.mjs";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Constants
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Estimated ratio of a tool_use summary vs the original input size.
|
|
22
|
+
* Edit/Write keep ~80% (diffs preserved), Read/Grep/Glob keep ~5% (note only),
|
|
23
|
+
* average across all tool types is roughly 15%.
|
|
24
|
+
*/
|
|
25
|
+
const TOOL_USE_SUMMARY_RATIO = 0.15;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Main estimator
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Estimate post-compaction token percentages for Smart Compact and Keep Recent.
|
|
33
|
+
*
|
|
34
|
+
* Does a fast single-pass categorisation of transcript bytes into
|
|
35
|
+
* "keep" vs "remove" buckets, then projects the savings onto the
|
|
36
|
+
* real token count.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} transcriptPath - Path to the JSONL transcript
|
|
39
|
+
* @param {number} currentTokens - Current real token count from API
|
|
40
|
+
* @param {number} maxTokens - Model's max token limit
|
|
41
|
+
* @returns {{ smartPct: number, recentPct: number }} Estimated post-compaction percentages (0-100)
|
|
42
|
+
*/
|
|
43
|
+
export function estimateSavings(
|
|
44
|
+
transcriptPath,
|
|
45
|
+
currentTokens,
|
|
46
|
+
maxTokens,
|
|
47
|
+
baselineOverhead = 0,
|
|
48
|
+
) {
|
|
49
|
+
if (!transcriptPath || !Number.isFinite(maxTokens) || maxTokens <= 0) {
|
|
50
|
+
return { smartPct: 0, recentPct: 0 };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
fs.statSync(transcriptPath);
|
|
54
|
+
} catch {
|
|
55
|
+
return { smartPct: 0, recentPct: 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const lines = readTranscriptLines(transcriptPath);
|
|
60
|
+
const scan = categoriseBytes(lines);
|
|
61
|
+
|
|
62
|
+
// Smart Compact: remove noise, keep content
|
|
63
|
+
const totalBytes = scan.keepBytes + scan.removeBytes;
|
|
64
|
+
if (totalBytes === 0) return { smartPct: 0, recentPct: 0 };
|
|
65
|
+
|
|
66
|
+
// Per-session overhead (system prompt, tools, memory, skills).
|
|
67
|
+
// Uses measured baseline from first response when available.
|
|
68
|
+
const overhead = estimateOverhead(
|
|
69
|
+
currentTokens,
|
|
70
|
+
transcriptPath,
|
|
71
|
+
baselineOverhead,
|
|
72
|
+
);
|
|
73
|
+
const conversationTokens = Math.max(0, currentTokens - overhead);
|
|
74
|
+
|
|
75
|
+
const smartKeepRatio = scan.keepBytes / totalBytes;
|
|
76
|
+
const smartTokens =
|
|
77
|
+
Math.round(conversationTokens * smartKeepRatio) + overhead;
|
|
78
|
+
const smartPct = Number(((smartTokens / maxTokens) * 100).toFixed(1));
|
|
79
|
+
|
|
80
|
+
// Keep Recent: last 10 user exchanges, with same noise removal
|
|
81
|
+
const recentRatio =
|
|
82
|
+
scan.userExchanges > 0 ? Math.min(1, 10 / scan.userExchanges) : 1;
|
|
83
|
+
const recentTokens =
|
|
84
|
+
Math.round(conversationTokens * smartKeepRatio * recentRatio) + overhead;
|
|
85
|
+
const recentPct = Number(((recentTokens / maxTokens) * 100).toFixed(1));
|
|
86
|
+
|
|
87
|
+
return { smartPct, recentPct };
|
|
88
|
+
} catch {
|
|
89
|
+
return { smartPct: 0, recentPct: 0 };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Byte categorisation — single-pass scan
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Categorise transcript content bytes into "keep" vs "remove" buckets
|
|
99
|
+
* and count user exchanges.
|
|
100
|
+
*
|
|
101
|
+
* @param {string[]} lines - JSONL transcript lines
|
|
102
|
+
* @returns {{ keepBytes: number, removeBytes: number, userExchanges: number }}
|
|
103
|
+
*/
|
|
104
|
+
function categoriseBytes(lines) {
|
|
105
|
+
let keepBytes = 0;
|
|
106
|
+
let removeBytes = 0;
|
|
107
|
+
let userExchanges = 0;
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
let obj;
|
|
111
|
+
try {
|
|
112
|
+
obj = JSON.parse(line);
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// System / progress — entirely removed
|
|
118
|
+
if (obj.type === "system" || obj.type === "progress") {
|
|
119
|
+
removeBytes += Buffer.byteLength(line, "utf8");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (obj.type === "assistant" && obj.message?.content) {
|
|
124
|
+
const result = categoriseAssistantContent(obj.message.content);
|
|
125
|
+
keepBytes += result.keep;
|
|
126
|
+
removeBytes += result.remove;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (obj.type === "user" && obj.message?.content) {
|
|
131
|
+
const result = categoriseUserContent(obj.message.content);
|
|
132
|
+
keepBytes += result.keep;
|
|
133
|
+
removeBytes += result.remove;
|
|
134
|
+
if (result.hasUserText) userExchanges++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { keepBytes, removeBytes, userExchanges };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Content block categorisation helpers
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Categorise bytes from a single assistant content block.
|
|
147
|
+
* @param {object} block - A content block from an assistant message
|
|
148
|
+
* @returns {{ keep: number, remove: number }}
|
|
149
|
+
*/
|
|
150
|
+
function categoriseAssistantBlock(block) {
|
|
151
|
+
if (block.type === "text" && block.text) {
|
|
152
|
+
return { keep: Buffer.byteLength(block.text, "utf8"), remove: 0 };
|
|
153
|
+
}
|
|
154
|
+
if (block.type === "tool_use" && block.input) {
|
|
155
|
+
const inputBytes = Buffer.byteLength(JSON.stringify(block.input), "utf8");
|
|
156
|
+
return {
|
|
157
|
+
keep: Math.round(inputBytes * TOOL_USE_SUMMARY_RATIO),
|
|
158
|
+
remove: Math.round(inputBytes * (1 - TOOL_USE_SUMMARY_RATIO)),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
162
|
+
const thinkBytes = block.thinking
|
|
163
|
+
? Buffer.byteLength(block.thinking, "utf8")
|
|
164
|
+
: 100;
|
|
165
|
+
return { keep: 0, remove: thinkBytes };
|
|
166
|
+
}
|
|
167
|
+
return { keep: 0, remove: 0 };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Categorise bytes from assistant message content (array or string).
|
|
172
|
+
* @param {Array|string} content - The message.content field
|
|
173
|
+
* @returns {{ keep: number, remove: number }}
|
|
174
|
+
*/
|
|
175
|
+
function categoriseAssistantContent(content) {
|
|
176
|
+
if (typeof content === "string") {
|
|
177
|
+
return { keep: Buffer.byteLength(content, "utf8"), remove: 0 };
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(content)) {
|
|
180
|
+
return { keep: 0, remove: 0 };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let keep = 0;
|
|
184
|
+
let remove = 0;
|
|
185
|
+
for (const block of content) {
|
|
186
|
+
const result = categoriseAssistantBlock(block);
|
|
187
|
+
keep += result.keep;
|
|
188
|
+
remove += result.remove;
|
|
189
|
+
}
|
|
190
|
+
return { keep, remove };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Categorise bytes from a single user content block.
|
|
195
|
+
* @param {object} block - A content block from a user message
|
|
196
|
+
* @returns {{ keep: number, remove: number, isText: boolean }}
|
|
197
|
+
*/
|
|
198
|
+
function categoriseUserBlock(block) {
|
|
199
|
+
if (block.type === "text" && block.text) {
|
|
200
|
+
return {
|
|
201
|
+
keep: Buffer.byteLength(block.text, "utf8"),
|
|
202
|
+
remove: 0,
|
|
203
|
+
isText: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (block.type === "tool_result") {
|
|
207
|
+
const resultContent = block.content;
|
|
208
|
+
let resultBytes;
|
|
209
|
+
if (resultContent) {
|
|
210
|
+
const raw =
|
|
211
|
+
typeof resultContent === "string"
|
|
212
|
+
? resultContent
|
|
213
|
+
: JSON.stringify(resultContent);
|
|
214
|
+
resultBytes = Buffer.byteLength(raw, "utf8");
|
|
215
|
+
} else {
|
|
216
|
+
resultBytes = 0;
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
keep: Math.round(resultBytes * 0.1),
|
|
220
|
+
remove: Math.round(resultBytes * 0.9),
|
|
221
|
+
isText: false,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { keep: 0, remove: 0, isText: false };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Categorise bytes from user message content (array or string).
|
|
229
|
+
* @param {Array|string} content - The message.content field
|
|
230
|
+
* @returns {{ keep: number, remove: number, hasUserText: boolean }}
|
|
231
|
+
*/
|
|
232
|
+
function categoriseUserContent(content) {
|
|
233
|
+
if (typeof content === "string") {
|
|
234
|
+
return {
|
|
235
|
+
keep: Buffer.byteLength(content, "utf8"),
|
|
236
|
+
remove: 0,
|
|
237
|
+
hasUserText: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (!Array.isArray(content)) {
|
|
241
|
+
return { keep: 0, remove: 0, hasUserText: false };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let keep = 0;
|
|
245
|
+
let remove = 0;
|
|
246
|
+
let hasUserText = false;
|
|
247
|
+
for (const block of content) {
|
|
248
|
+
const result = categoriseUserBlock(block);
|
|
249
|
+
keep += result.keep;
|
|
250
|
+
remove += result.remove;
|
|
251
|
+
if (result.isText) hasUserText = true;
|
|
252
|
+
}
|
|
253
|
+
return { keep, remove, hasUserText };
|
|
254
|
+
}
|