@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/lib/estimate.mjs DELETED
@@ -1,254 +0,0 @@
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
- }
@@ -1,262 +0,0 @@
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, beforeEach, describe, it } from "node:test";
6
- import { estimateSavings } from "../lib/estimate.mjs";
7
-
8
- let tmpDir;
9
-
10
- beforeEach(() => {
11
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-estimate-test-"));
12
- });
13
-
14
- afterEach(() => {
15
- fs.rmSync(tmpDir, { recursive: true, force: true });
16
- });
17
-
18
- /** Write a JSONL transcript file from an array of objects. */
19
- function writeTranscript(name, lines) {
20
- const p = path.join(tmpDir, name);
21
- fs.writeFileSync(p, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
22
- return p;
23
- }
24
-
25
- // ---------------------------------------------------------------------------
26
- // Helpers for building transcript lines
27
- // ---------------------------------------------------------------------------
28
-
29
- function userText(text) {
30
- return { type: "user", message: { role: "user", content: text } };
31
- }
32
-
33
- function userBlocks(blocks) {
34
- return { type: "user", message: { role: "user", content: blocks } };
35
- }
36
-
37
- function assistantText(text) {
38
- return {
39
- type: "assistant",
40
- message: {
41
- role: "assistant",
42
- content: [{ type: "text", text }],
43
- },
44
- };
45
- }
46
-
47
- function systemMsg(text) {
48
- return { type: "system", message: { content: text } };
49
- }
50
-
51
- function progressMsg(text) {
52
- return { type: "progress", message: { content: text } };
53
- }
54
-
55
- // ---------------------------------------------------------------------------
56
- // Tests
57
- // ---------------------------------------------------------------------------
58
-
59
- describe("estimateSavings", () => {
60
- it("returns zeros when transcriptPath is null", () => {
61
- const result = estimateSavings(null, 50000, 200000);
62
- assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
63
- });
64
-
65
- it("returns zeros when transcriptPath does not exist", () => {
66
- const result = estimateSavings("/nonexistent/path.jsonl", 50000, 200000);
67
- assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
68
- });
69
-
70
- it("returns zeros when transcript is empty", () => {
71
- const p = path.join(tmpDir, "empty.jsonl");
72
- fs.writeFileSync(p, "");
73
- const result = estimateSavings(p, 50000, 200000);
74
- assert.deepStrictEqual(result, { smartPct: 0, recentPct: 0 });
75
- });
76
-
77
- it("system/progress messages are all removable — estimates lower than raw percentage", () => {
78
- const p = writeTranscript("sys-progress.jsonl", [
79
- systemMsg("System prompt content here with some length to it"),
80
- progressMsg("Loading tools..."),
81
- systemMsg("Another system message with extra content padding"),
82
- progressMsg("Still loading more tools and resources..."),
83
- ]);
84
-
85
- const rawPct = (50000 / 200000) * 100; // 25%
86
- const result = estimateSavings(p, 50000, 200000);
87
- assert.ok(result.smartPct <= rawPct);
88
- assert.ok(result.recentPct <= rawPct);
89
- assert.equal(result.smartPct, result.recentPct);
90
- });
91
-
92
- it("user text + assistant text — both kept, estimates reflect kept content", () => {
93
- const p = writeTranscript("text-only.jsonl", [
94
- userText("Please help me refactor the auth module"),
95
- assistantText(
96
- "I will refactor the auth module by extracting the token validation into a separate function.",
97
- ),
98
- ]);
99
-
100
- const result = estimateSavings(p, 50000, 200000);
101
- assert.ok(result.smartPct > 10);
102
- assert.ok(result.recentPct > 0);
103
- });
104
-
105
- it("large tool_result content is mostly removed — low estimate", () => {
106
- const bigContent = "x".repeat(50000);
107
- const p = writeTranscript("tool-result.jsonl", [
108
- userText("Read the config file"),
109
- assistantText("I will read the config file for you."),
110
- userBlocks([
111
- { type: "text", text: "Here is the result" },
112
- {
113
- type: "tool_result",
114
- tool_use_id: "toolu_123",
115
- content: bigContent,
116
- },
117
- ]),
118
- ]);
119
-
120
- const rawPct = (80000 / 200000) * 100; // 40%
121
- const result = estimateSavings(p, 80000, 200000);
122
- assert.ok(result.smartPct < rawPct);
123
- });
124
-
125
- it("baselineOverhead parameter affects the result", () => {
126
- const bigContent = "y".repeat(20000);
127
- const p = writeTranscript("overhead.jsonl", [
128
- userText("Analyse the file"),
129
- assistantText("I will read and analyse the file."),
130
- userBlocks([
131
- { type: "text", text: "Result" },
132
- {
133
- type: "tool_result",
134
- tool_use_id: "toolu_oh1",
135
- content: bigContent,
136
- },
137
- ]),
138
- assistantText("The file looks good."),
139
- ]);
140
-
141
- const withoutOverhead = estimateSavings(p, 80000, 200000, 0);
142
- const withOverhead = estimateSavings(p, 80000, 200000, 40000);
143
- assert.notEqual(withoutOverhead.smartPct, withOverhead.smartPct);
144
- });
145
-
146
- it("returns zeros when maxTokens is zero or NaN", () => {
147
- const p = writeTranscript("zero-max.jsonl", [
148
- userText("hello"),
149
- assistantText("hi"),
150
- ]);
151
- assert.deepStrictEqual(estimateSavings(p, 1000, 0), {
152
- smartPct: 0,
153
- recentPct: 0,
154
- });
155
- assert.deepStrictEqual(estimateSavings(p, 1000, NaN), {
156
- smartPct: 0,
157
- recentPct: 0,
158
- });
159
- assert.deepStrictEqual(estimateSavings(p, 1000, -100), {
160
- smartPct: 0,
161
- recentPct: 0,
162
- });
163
- });
164
-
165
- it("thinking blocks are categorised as removable", () => {
166
- const p = writeTranscript("thinking.jsonl", [
167
- userText("solve this problem"),
168
- {
169
- type: "assistant",
170
- message: {
171
- role: "assistant",
172
- content: [
173
- {
174
- type: "thinking",
175
- thinking: "Let me think about this carefully... ".repeat(100),
176
- },
177
- { type: "text", text: "The answer is 42." },
178
- ],
179
- },
180
- },
181
- ]);
182
-
183
- const rawPct = (50000 / 200000) * 100;
184
- const result = estimateSavings(p, 50000, 200000);
185
- assert.ok(result.smartPct < rawPct);
186
- });
187
-
188
- it("tool_use blocks are partially kept (summary ratio)", () => {
189
- const p = writeTranscript("tool-use.jsonl", [
190
- userText("read the file"),
191
- {
192
- type: "assistant",
193
- message: {
194
- role: "assistant",
195
- content: [
196
- {
197
- type: "tool_use",
198
- id: "t1",
199
- name: "Read",
200
- input: { file_path: "/some/very/long/path/to/file.js" },
201
- },
202
- { type: "text", text: "Here is the file content." },
203
- ],
204
- },
205
- },
206
- ]);
207
-
208
- const result = estimateSavings(p, 50000, 200000);
209
- assert.ok(result.smartPct > 0);
210
- });
211
-
212
- it("assistant string content (non-array) is categorised as kept", () => {
213
- const p = writeTranscript("string-content.jsonl", [
214
- userText("hello"),
215
- {
216
- type: "assistant",
217
- message: { role: "assistant", content: "Simple string response" },
218
- },
219
- ]);
220
-
221
- const result = estimateSavings(p, 50000, 200000);
222
- assert.ok(result.smartPct > 0);
223
- });
224
-
225
- it("redacted_thinking blocks are categorised as removable", () => {
226
- const p = writeTranscript("redacted.jsonl", [
227
- userText("think about this"),
228
- {
229
- type: "assistant",
230
- message: {
231
- role: "assistant",
232
- content: [
233
- { type: "redacted_thinking" },
234
- { type: "text", text: "Done thinking." },
235
- ],
236
- },
237
- },
238
- ]);
239
-
240
- const result = estimateSavings(p, 50000, 200000);
241
- assert.ok(result.smartPct > 0);
242
- });
243
-
244
- it("userExchanges counting — 20 exchanges makes recentPct less than smartPct", () => {
245
- const padding = "A".repeat(5000);
246
- const lines = [];
247
- for (let i = 0; i < 20; i++) {
248
- lines.push(userText(`User message ${i + 1}: ${padding}`));
249
- lines.push(assistantText(`Response ${i + 1}: ${padding}`));
250
- }
251
- const p = writeTranscript("twenty-exchanges.jsonl", lines);
252
-
253
- const result = estimateSavings(p, 60000, 200000);
254
- assert.ok(result.smartPct > 0);
255
- assert.ok(result.recentPct > 0);
256
- assert.ok(result.recentPct < result.smartPct);
257
-
258
- const ratio = result.recentPct / result.smartPct;
259
- assert.ok(ratio < 0.85);
260
- assert.ok(ratio > 0.4);
261
- });
262
- });