@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,369 @@
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 {
7
+ applyTiers,
8
+ coalesceEdits,
9
+ readTranscriptLines,
10
+ } from "../lib/transcript.mjs";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // readTranscriptLines
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("readTranscriptLines", () => {
17
+ let tmpDir;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-rtl-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ it("reads a small JSONL file completely", () => {
28
+ const filePath = path.join(tmpDir, "small.jsonl");
29
+ const lines = [
30
+ JSON.stringify({
31
+ type: "user",
32
+ message: { role: "user", content: "hello" },
33
+ }),
34
+ JSON.stringify({
35
+ type: "assistant",
36
+ message: { role: "assistant", content: [{ type: "text", text: "hi" }] },
37
+ }),
38
+ ];
39
+ fs.writeFileSync(filePath, `${lines.join("\n")}\n`);
40
+
41
+ const result = readTranscriptLines(filePath);
42
+ assert.equal(result.length, 2);
43
+ // Verify they are valid JSON
44
+ const parsed0 = JSON.parse(result[0]);
45
+ assert.equal(parsed0.type, "user");
46
+ const parsed1 = JSON.parse(result[1]);
47
+ assert.equal(parsed1.type, "assistant");
48
+ });
49
+
50
+ it("returns empty array for empty file", () => {
51
+ const filePath = path.join(tmpDir, "empty.jsonl");
52
+ fs.writeFileSync(filePath, "");
53
+
54
+ const result = readTranscriptLines(filePath);
55
+ assert.deepEqual(result, []);
56
+ });
57
+
58
+ it("filters out empty lines", () => {
59
+ const filePath = path.join(tmpDir, "gaps.jsonl");
60
+ const line1 = JSON.stringify({
61
+ type: "user",
62
+ message: { role: "user", content: "a" },
63
+ });
64
+ const line2 = JSON.stringify({
65
+ type: "user",
66
+ message: { role: "user", content: "b" },
67
+ });
68
+ // Write with extra blank lines
69
+ fs.writeFileSync(filePath, `${line1}\n\n\n${line2}\n\n`);
70
+
71
+ const result = readTranscriptLines(filePath);
72
+ assert.equal(result.length, 2);
73
+ });
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers: build exchange messages for applyTiers tests
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Build an array of message strings representing N user exchanges.
82
+ * Each exchange has one **User:** message and one **Assistant:** message.
83
+ */
84
+ function buildExchanges(n, opts = {}) {
85
+ const messages = [];
86
+ for (let i = 0; i < n; i++) {
87
+ messages.push(`**User:** Question ${i + 1}`);
88
+ const assistantText = opts.assistantTextFn
89
+ ? opts.assistantTextFn(i)
90
+ : `Answer ${i + 1}`;
91
+ messages.push(`**Assistant:** ${assistantText}`);
92
+ // Optionally add a tool result after the assistant message
93
+ if (opts.toolResultFn) {
94
+ const result = opts.toolResultFn(i);
95
+ if (result) messages.push(result);
96
+ }
97
+ }
98
+ return messages;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // applyTiers
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("applyTiers", () => {
106
+ it("returns unchanged for <= 20 exchanges", () => {
107
+ const messages = buildExchanges(20);
108
+ const result = applyTiers(messages);
109
+ assert.deepEqual(result, messages);
110
+ });
111
+
112
+ it("returns unchanged for exactly 20 exchanges", () => {
113
+ const messages = buildExchanges(20);
114
+ const original = [...messages];
115
+ const result = applyTiers(messages);
116
+ assert.deepEqual(result, original);
117
+ });
118
+
119
+ it("compresses cold-tier assistant text (> 500 chars trimmed)", () => {
120
+ // 25 exchanges: exchanges 1-5 are cold (25 - 5 = 20, fromEnd > 20)
121
+ const longText = "A".repeat(1000);
122
+ const messages = buildExchanges(25, {
123
+ assistantTextFn: (i) => (i < 5 ? longText : `Short answer ${i}`),
124
+ });
125
+
126
+ const result = applyTiers(messages);
127
+
128
+ // Cold-tier assistant messages (first 5 exchanges) should be trimmed
129
+ // Exchange 0 assistant is at index 1
130
+ for (let i = 0; i < 5; i++) {
131
+ const assistantIdx = i * 2 + 1;
132
+ assert.ok(
133
+ result[assistantIdx].length < messages[assistantIdx].length,
134
+ `Cold-tier assistant at exchange ${i} should be compressed`,
135
+ );
136
+ assert.ok(
137
+ result[assistantIdx].includes("trimmed from middle"),
138
+ `Cold-tier assistant at exchange ${i} should contain trim marker`,
139
+ );
140
+ }
141
+ });
142
+
143
+ it("compresses cold-tier tool results (> 200 chars trimmed)", () => {
144
+ const longResult = `${"R".repeat(500)}`;
145
+ const messages = buildExchanges(25, {
146
+ toolResultFn: (i) => (i < 3 ? `\u2190 ${longResult}` : null),
147
+ });
148
+
149
+ const result = applyTiers(messages);
150
+
151
+ // Cold-tier tool results (exchanges 0-2) should be trimmed
152
+ // Exchange 0: user(0), assistant(1), tool(2)
153
+ // Exchange 1: user(3), assistant(4), tool(5)
154
+ // Exchange 2: user(6), assistant(7), tool(8)
155
+ for (let e = 0; e < 3; e++) {
156
+ const toolIdx = e * 3 + 2;
157
+ assert.ok(
158
+ result[toolIdx].length < messages[toolIdx].length,
159
+ `Cold-tier tool result at exchange ${e} should be compressed`,
160
+ );
161
+ assert.ok(
162
+ result[toolIdx].includes("trimmed from middle"),
163
+ `Cold-tier tool result at exchange ${e} should contain trim marker`,
164
+ );
165
+ }
166
+ });
167
+
168
+ it("never compresses user messages even in cold tier", () => {
169
+ const longUserText = "U".repeat(2000);
170
+ const messages = [];
171
+ for (let i = 0; i < 25; i++) {
172
+ messages.push(`**User:** ${i < 5 ? longUserText : `Q${i}`}`);
173
+ messages.push(`**Assistant:** A${i}`);
174
+ }
175
+
176
+ const result = applyTiers(messages);
177
+
178
+ // Cold-tier user messages (first 5) should be untouched
179
+ for (let i = 0; i < 5; i++) {
180
+ assert.equal(
181
+ result[i * 2],
182
+ messages[i * 2],
183
+ `User message at exchange ${i} should never be compressed`,
184
+ );
185
+ }
186
+ });
187
+
188
+ it("preserves hot-tier messages (last 5) untouched", () => {
189
+ const longText = "X".repeat(1000);
190
+ const messages = buildExchanges(25, {
191
+ assistantTextFn: () => longText,
192
+ });
193
+
194
+ const result = applyTiers(messages);
195
+
196
+ // Hot tier = last 5 exchanges = exchanges 20-24 (indices 40-49)
197
+ for (let e = 20; e < 25; e++) {
198
+ const assistantIdx = e * 2 + 1;
199
+ assert.equal(
200
+ result[assistantIdx],
201
+ messages[assistantIdx],
202
+ `Hot-tier assistant at exchange ${e} should be untouched`,
203
+ );
204
+ }
205
+ });
206
+
207
+ it("preserves edit diffs in cold tier assistant messages", () => {
208
+ const editBlock = [
209
+ "I will update the file.",
210
+ "\u2192 Edit `src/app.js`:",
211
+ " old: |",
212
+ " const x = 1",
213
+ " new: |",
214
+ " const x = 2",
215
+ ].join("\n");
216
+
217
+ const messages = buildExchanges(25, {
218
+ assistantTextFn: (i) => (i < 3 ? editBlock : `Short ${i}`),
219
+ });
220
+
221
+ const result = applyTiers(messages);
222
+
223
+ // Cold-tier assistant messages with edit diffs should preserve the old:|new: patterns
224
+ for (let i = 0; i < 3; i++) {
225
+ const assistantIdx = i * 2 + 1;
226
+ assert.ok(
227
+ result[assistantIdx].includes("old: |"),
228
+ `Edit diff old: should be preserved in cold tier exchange ${i}`,
229
+ );
230
+ assert.ok(
231
+ result[assistantIdx].includes("new: |"),
232
+ `Edit diff new: should be preserved in cold tier exchange ${i}`,
233
+ );
234
+ }
235
+ });
236
+
237
+ it("preserves errors in cold tier results", () => {
238
+ // Error responses contain "Error" or "error" typically
239
+ const messages = buildExchanges(25, {
240
+ toolResultFn: (i) =>
241
+ i < 3 ? `\u2190 Error: ${"E".repeat(500)} something failed` : null,
242
+ });
243
+
244
+ const result = applyTiers(messages);
245
+
246
+ // Error results in cold tier should be preserved (isErrorResponse check)
247
+ for (let e = 0; e < 3; e++) {
248
+ const toolIdx = e * 3 + 2;
249
+ assert.equal(
250
+ result[toolIdx],
251
+ messages[toolIdx],
252
+ `Error result at exchange ${e} should be preserved in cold tier`,
253
+ );
254
+ }
255
+ });
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // coalesceEdits
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe("coalesceEdits", () => {
263
+ it("coalesces two consecutive edits to same file", () => {
264
+ const messages = [
265
+ "**User:** Update the config",
266
+ [
267
+ "**Assistant:** I'll update it in two steps.",
268
+ "\u2192 Edit `config.js`:",
269
+ " old: |",
270
+ " const x = 1",
271
+ " new: |",
272
+ " const x = 2",
273
+ "\u2192 Edit `config.js`:",
274
+ " old: |",
275
+ " const x = 2",
276
+ " new: |",
277
+ " const x = 3",
278
+ ].join("\n"),
279
+ ];
280
+
281
+ const result = coalesceEdits(messages);
282
+
283
+ assert.equal(result.length, 2);
284
+ // The coalesced edit should have first old (const x = 1) and last new (const x = 3)
285
+ assert.ok(
286
+ result[1].includes("const x = 1"),
287
+ "Should contain first edit's old_string",
288
+ );
289
+ assert.ok(
290
+ result[1].includes("const x = 3"),
291
+ "Should contain last edit's new_string",
292
+ );
293
+ assert.ok(
294
+ result[1].includes("2 edits coalesced"),
295
+ "Should show coalesced count",
296
+ );
297
+ // The intermediate value should not be the old_string
298
+ assert.ok(
299
+ !result[1].includes("old: |\n const x = 2"),
300
+ "Intermediate old_string should be removed",
301
+ );
302
+ });
303
+
304
+ it("keeps edits to different files independent", () => {
305
+ const messages = [
306
+ "**User:** Update both files",
307
+ [
308
+ "**Assistant:** Updating.",
309
+ "\u2192 Edit `a.js`:",
310
+ " old: |",
311
+ " const a = 1",
312
+ " new: |",
313
+ " const a = 2",
314
+ "\u2192 Edit `b.js`:",
315
+ " old: |",
316
+ " const b = 1",
317
+ " new: |",
318
+ " const b = 2",
319
+ ].join("\n"),
320
+ ];
321
+
322
+ const result = coalesceEdits(messages);
323
+
324
+ assert.equal(result.length, 2);
325
+ // Both edits should remain since they're to different files
326
+ assert.ok(result[1].includes("a.js"), "Should keep edit to a.js");
327
+ assert.ok(result[1].includes("b.js"), "Should keep edit to b.js");
328
+ assert.ok(
329
+ !result[1].includes("coalesced"),
330
+ "Should not coalesce different-file edits",
331
+ );
332
+ });
333
+
334
+ it("returns messages unchanged when no consecutive same-file edits", () => {
335
+ const messages = [
336
+ "**User:** Do something",
337
+ "**Assistant:** Here is my reasoning about the change.",
338
+ "**User:** And another thing",
339
+ "**Assistant:** Sure, done.",
340
+ ];
341
+
342
+ const result = coalesceEdits(messages);
343
+ assert.deepEqual(result, messages);
344
+ });
345
+
346
+ it("handles a single edit (no coalescing needed)", () => {
347
+ const messages = [
348
+ "**User:** Fix the bug",
349
+ [
350
+ "**Assistant:** Fixed it.",
351
+ "\u2192 Edit `bug.js`:",
352
+ " old: |",
353
+ " return null",
354
+ " new: |",
355
+ " return value",
356
+ ].join("\n"),
357
+ ];
358
+
359
+ const result = coalesceEdits(messages);
360
+
361
+ assert.equal(result.length, 2);
362
+ assert.ok(result[1].includes("return null"), "old_string preserved");
363
+ assert.ok(result[1].includes("return value"), "new_string preserved");
364
+ assert.ok(
365
+ !result[1].includes("coalesced"),
366
+ "Single edit should not show coalesced marker",
367
+ );
368
+ });
369
+ });