@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
@@ -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
+ }