@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,582 @@
1
+ /**
2
+ * Critical integration tests — verify actual data integrity, not just code paths.
3
+ * These tests protect against the scenarios that would lose or corrupt user context.
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { afterEach, beforeEach, describe, it } from "node:test";
12
+ import { performCompaction } from "../lib/checkpoint.mjs";
13
+ import { extractConversation, extractRecent } from "../lib/transcript.mjs";
14
+
15
+ const HOOK_PATH = path.resolve("hooks/submit.mjs");
16
+
17
+ let tmpDir, cwd, dataDir, transcriptPath;
18
+
19
+ const HIGH_USAGE = {
20
+ input_tokens: 5000,
21
+ cache_creation_input_tokens: 0,
22
+ cache_read_input_tokens: 0,
23
+ output_tokens: 10,
24
+ };
25
+
26
+ function writeLine(obj) {
27
+ fs.appendFileSync(transcriptPath, `${JSON.stringify(obj)}\n`);
28
+ }
29
+
30
+ function makeUser(text) {
31
+ return { type: "user", message: { role: "user", content: text } };
32
+ }
33
+
34
+ function makeAssistant(text, usage) {
35
+ return {
36
+ type: "assistant",
37
+ message: {
38
+ role: "assistant",
39
+ model: "claude-sonnet-4-20250514",
40
+ content: [{ type: "text", text }],
41
+ usage: usage || undefined,
42
+ },
43
+ };
44
+ }
45
+
46
+ function runHook(input) {
47
+ const stdin = JSON.stringify({
48
+ session_id: "test-session-1234",
49
+ prompt: input.prompt ?? "",
50
+ transcript_path: input.transcript_path ?? transcriptPath,
51
+ cwd: input.cwd ?? cwd,
52
+ ...input,
53
+ });
54
+ try {
55
+ const stdout = execFileSync("node", [HOOK_PATH], {
56
+ input: stdin,
57
+ encoding: "utf8",
58
+ timeout: 5000,
59
+ env: { ...process.env, CLAUDE_PLUGIN_DATA: dataDir },
60
+ });
61
+ return stdout ? JSON.parse(stdout) : null;
62
+ } catch (e) {
63
+ if (e.status === 0 && !e.stdout?.trim()) return null;
64
+ if (e.status === 0 && e.stdout?.trim()) return JSON.parse(e.stdout);
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ beforeEach(() => {
70
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-integ-"));
71
+ cwd = path.join(tmpDir, "project");
72
+ dataDir = path.join(tmpDir, "data");
73
+ fs.mkdirSync(cwd, { recursive: true });
74
+ fs.mkdirSync(dataDir, { recursive: true });
75
+ fs.mkdirSync(path.join(dataDir, "checkpoints"), { recursive: true });
76
+ transcriptPath = path.join(tmpDir, "transcript.jsonl");
77
+ fs.writeFileSync(
78
+ path.join(dataDir, "config.json"),
79
+ JSON.stringify({ threshold: 0.01, max_tokens: 200000 }),
80
+ );
81
+ });
82
+
83
+ afterEach(() => {
84
+ fs.rmSync(tmpDir, { recursive: true, force: true });
85
+ });
86
+
87
+ // =========================================================================
88
+ // 1. Checkpoint content verification — the checkpoint must contain the
89
+ // actual conversation, not garbage or empty content
90
+ // =========================================================================
91
+ describe("checkpoint content integrity", () => {
92
+ it("smart compact checkpoint contains all user and assistant text", () => {
93
+ writeLine(makeUser("Please refactor the auth module"));
94
+ writeLine(
95
+ makeAssistant("I'll refactor the auth module for you.", HIGH_USAGE),
96
+ );
97
+ writeLine(makeUser("Now add unit tests for the refactored code"));
98
+ writeLine(
99
+ makeAssistant("Done, I've added comprehensive unit tests.", HIGH_USAGE),
100
+ );
101
+
102
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
103
+ const result = performCompaction({
104
+ mode: "smart",
105
+ transcriptPath,
106
+ sessionId: "test-session-1234",
107
+ });
108
+
109
+ assert.ok(result, "performCompaction should return a result");
110
+ assert.ok(result.checkpointPath, "Should have a checkpoint path");
111
+ const checkpoint = fs.readFileSync(result.checkpointPath, "utf8");
112
+
113
+ // Verify ALL user messages are present
114
+ assert.ok(
115
+ checkpoint.includes("refactor the auth module"),
116
+ "Missing user message 1",
117
+ );
118
+ assert.ok(checkpoint.includes("add unit tests"), "Missing user message 2");
119
+ // Verify ALL assistant messages are present
120
+ assert.ok(
121
+ checkpoint.includes("refactor the auth module for you"),
122
+ "Missing assistant message 1",
123
+ );
124
+ assert.ok(
125
+ checkpoint.includes("comprehensive unit tests"),
126
+ "Missing assistant message 2",
127
+ );
128
+ // Verify the checkpoint has the header
129
+ assert.ok(checkpoint.includes("# Context Checkpoint (Smart Compact)"));
130
+ });
131
+
132
+ it("keep recent checkpoint contains the correct last N messages", () => {
133
+ // Write 6 exchanges (12 messages)
134
+ for (let i = 0; i < 6; i++) {
135
+ writeLine(makeUser(`question number ${i}`));
136
+ writeLine(makeAssistant(`answer number ${i}`, HIGH_USAGE));
137
+ }
138
+
139
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
140
+ const result = performCompaction({
141
+ mode: "recent",
142
+ transcriptPath,
143
+ sessionId: "test-session-1234",
144
+ });
145
+
146
+ assert.ok(result, "performCompaction should return a result");
147
+ assert.ok(result.checkpointPath, "Should have a checkpoint path");
148
+ const checkpoint = fs.readFileSync(result.checkpointPath, "utf8");
149
+
150
+ // All 12 messages should be present (20 > 12)
151
+ for (let i = 0; i < 6; i++) {
152
+ assert.ok(
153
+ checkpoint.includes(`question number ${i}`),
154
+ `Missing question ${i}`,
155
+ );
156
+ assert.ok(
157
+ checkpoint.includes(`answer number ${i}`),
158
+ `Missing answer ${i}`,
159
+ );
160
+ }
161
+ });
162
+
163
+ it("smart compact replaces tool-only responses with placeholder and tracks files", () => {
164
+ writeLine(makeUser("read the file please"));
165
+ // Tool-only response (no text block)
166
+ writeLine({
167
+ type: "assistant",
168
+ message: {
169
+ role: "assistant",
170
+ model: "claude-sonnet-4-20250514",
171
+ content: [
172
+ {
173
+ type: "tool_use",
174
+ id: "t1",
175
+ name: "Read",
176
+ input: { path: "/foo.js" },
177
+ },
178
+ ],
179
+ usage: HIGH_USAGE,
180
+ },
181
+ });
182
+ // Text response
183
+ writeLine(
184
+ makeAssistant(
185
+ "Here is the file content, the bug is on line 42.",
186
+ HIGH_USAGE,
187
+ ),
188
+ );
189
+ writeLine(makeUser("fix line 42"));
190
+ writeLine(
191
+ makeAssistant("Fixed! The issue was a null pointer.", HIGH_USAGE),
192
+ );
193
+
194
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
195
+ const result = performCompaction({
196
+ mode: "smart",
197
+ transcriptPath,
198
+ sessionId: "test-session-1234",
199
+ });
200
+
201
+ assert.ok(result, "performCompaction should return a result");
202
+ assert.ok(result.checkpointPath, "Should have a checkpoint path");
203
+ const checkpoint = fs.readFileSync(result.checkpointPath, "utf8");
204
+
205
+ // Text messages preserved
206
+ assert.ok(checkpoint.includes("read the file please"));
207
+ assert.ok(checkpoint.includes("bug is on line 42"));
208
+ assert.ok(checkpoint.includes("fix line 42"));
209
+ assert.ok(checkpoint.includes("null pointer"));
210
+ // Tool-only response gets tool summary instead of placeholder
211
+ assert.ok(
212
+ checkpoint.includes("→ Read"),
213
+ "Should have tool summary for Read",
214
+ );
215
+ assert.ok(checkpoint.includes("/foo.js"), "Should reference the file path");
216
+ });
217
+ });
218
+
219
+ // =========================================================================
220
+ // 2. Message ordering — messages must come out in the same order
221
+ // =========================================================================
222
+ describe("message ordering preservation", () => {
223
+ it("extractConversation preserves chronological order", () => {
224
+ for (let i = 1; i <= 5; i++) {
225
+ writeLine(makeUser(`step ${i} request`));
226
+ writeLine(makeAssistant(`step ${i} done`));
227
+ }
228
+
229
+ const result = extractConversation(transcriptPath);
230
+
231
+ // Every message found
232
+ for (let i = 1; i <= 5; i++) {
233
+ assert.ok(result.includes(`step ${i} request`), `Missing request ${i}`);
234
+ assert.ok(result.includes(`step ${i} done`), `Missing done ${i}`);
235
+ }
236
+
237
+ // Find positions of User: and Asst: tagged messages
238
+ // (skip the Session State header which may reference the last message)
239
+ const bodyStart = result.indexOf("---\n\n[");
240
+ assert.ok(bodyStart >= 0, "Should have message body after header");
241
+ const body = result.slice(bodyStart);
242
+ const positions = [];
243
+ for (let i = 1; i <= 5; i++) {
244
+ positions.push(body.indexOf(`step ${i} request`));
245
+ positions.push(body.indexOf(`step ${i} done`));
246
+ }
247
+
248
+ // Strictly ascending order within the body
249
+ for (let i = 1; i < positions.length; i++) {
250
+ assert.ok(positions[i] > positions[i - 1], `Message ${i} out of order`);
251
+ }
252
+ });
253
+
254
+ it("extractRecent preserves chronological order within window", () => {
255
+ for (let i = 1; i <= 10; i++) {
256
+ writeLine(makeUser(`msg ${i}`));
257
+ writeLine(makeAssistant(`reply ${i}`));
258
+ }
259
+
260
+ const result = extractRecent(transcriptPath, 6); // last 6 messages
261
+ // Search in message body after the Session State header
262
+ const bodyStart = result.indexOf("---\n\n[");
263
+ assert.ok(bodyStart >= 0, "Should have message body after header");
264
+ const body = result.slice(bodyStart);
265
+ const pos8 = body.indexOf("User: msg 8");
266
+ const pos9 = body.indexOf("User: msg 9");
267
+ const pos10 = body.indexOf("User: msg 10");
268
+ assert.ok(pos8 >= 0, "msg 8 should be present");
269
+ assert.ok(pos9 >= 0, "msg 9 should be present");
270
+ assert.ok(pos10 >= 0, "msg 10 should be present");
271
+ assert.ok(pos8 < pos9, "msg 8 should come before msg 9");
272
+ assert.ok(pos9 < pos10, "msg 9 should come before msg 10");
273
+ });
274
+ });
275
+
276
+ // =========================================================================
277
+ // 3. Successive compaction — second compact must not duplicate first
278
+ // =========================================================================
279
+ describe("successive compaction integrity", () => {
280
+ it("second extractConversation uses last marker as boundary", () => {
281
+ // Simulate a restored checkpoint (first compaction result)
282
+ writeLine({
283
+ type: "user",
284
+ message: {
285
+ role: "user",
286
+ content:
287
+ "[SMART COMPACT — restored checkpoint]\n\nUser: original question\n\nAsst: original answer",
288
+ },
289
+ });
290
+ // New messages after restore
291
+ writeLine(makeUser("follow-up question"));
292
+ writeLine(makeAssistant("follow-up answer"));
293
+
294
+ const result = extractConversation(transcriptPath);
295
+
296
+ // Preamble should contain the first compaction's content
297
+ assert.ok(result.includes("[SMART COMPACT"));
298
+ assert.ok(result.includes("original question"));
299
+ // New messages should be present
300
+ assert.ok(result.includes("follow-up question"));
301
+ assert.ok(result.includes("follow-up answer"));
302
+
303
+ // "follow-up question" appears in the message body; it may also
304
+ // appear in the Session State header's "Goal:" line as a summary.
305
+ // The key invariant is that it appears exactly once as a User: message.
306
+ const userMatches = result.match(/User: follow-up question/g);
307
+ assert.equal(
308
+ userMatches.length,
309
+ 1,
310
+ "follow-up question should appear exactly once as a User message",
311
+ );
312
+ });
313
+
314
+ it("markers from prior compactions are not included as user messages", () => {
315
+ writeLine({
316
+ type: "user",
317
+ message: {
318
+ role: "user",
319
+ content: "[KEEP RECENT — restored checkpoint]\n\nold stuff",
320
+ },
321
+ });
322
+ writeLine(makeUser("new stuff"));
323
+
324
+ const result = extractConversation(transcriptPath);
325
+ // The marker should be the preamble, not a User: entry
326
+ assert.ok(!result.includes("User: [KEEP RECENT"));
327
+ assert.ok(result.includes("User: new stuff"));
328
+ });
329
+ });
330
+
331
+ // =========================================================================
332
+ // 4. State file accuracy — headroom and recommendation must be correct
333
+ // =========================================================================
334
+ describe("state file accuracy", () => {
335
+ it("headroom is mathematically correct", () => {
336
+ writeLine(makeUser("hello"));
337
+ writeLine(makeAssistant("hi", HIGH_USAGE)); // 5000 tokens
338
+
339
+ runHook({ prompt: "test" });
340
+
341
+ const sf = path.join(dataDir, "state-test-session-1234.json");
342
+ const state = JSON.parse(fs.readFileSync(sf, "utf8"));
343
+
344
+ // threshold=0.01, max_tokens=200000, current=5000
345
+ // headroom = max(0, round(200000 * 0.01 - 5000)) = max(0, round(2000 - 5000)) = 0
346
+ assert.equal(state.headroom, 0);
347
+ assert.equal(state.threshold, 0.01);
348
+ });
349
+
350
+ it("recommendation says 'at threshold' when above", () => {
351
+ writeLine(makeUser("hello"));
352
+ writeLine(makeAssistant("hi", HIGH_USAGE)); // 5000 tokens, well above 0.01 * 200K = 2000
353
+
354
+ runHook({ prompt: "test" });
355
+
356
+ const sf = path.join(dataDir, "state-test-session-1234.json");
357
+ const state = JSON.parse(fs.readFileSync(sf, "utf8"));
358
+ assert.ok(state.recommendation.includes("At threshold"));
359
+ });
360
+
361
+ it("recommendation says 'all clear' when well below threshold", () => {
362
+ // Use high threshold so low usage is below 50% of threshold
363
+ fs.writeFileSync(
364
+ path.join(dataDir, "config.json"),
365
+ JSON.stringify({ threshold: 0.9, max_tokens: 200000 }),
366
+ );
367
+ writeLine(makeUser("hello"));
368
+ writeLine(
369
+ makeAssistant("hi", {
370
+ input_tokens: 100,
371
+ cache_creation_input_tokens: 0,
372
+ cache_read_input_tokens: 0,
373
+ output_tokens: 5,
374
+ }),
375
+ );
376
+
377
+ runHook({ prompt: "test" });
378
+
379
+ const sf = path.join(dataDir, "state-test-session-1234.json");
380
+ const state = JSON.parse(fs.readFileSync(sf, "utf8"));
381
+ assert.ok(state.recommendation.includes("All clear"));
382
+ });
383
+
384
+ it("recommendation says 'approaching' when between 50-100% of threshold", () => {
385
+ // threshold=0.10, usage=15000 → pct=7.5% → 75% of threshold
386
+ fs.writeFileSync(
387
+ path.join(dataDir, "config.json"),
388
+ JSON.stringify({ threshold: 0.1, max_tokens: 200000 }),
389
+ );
390
+ writeLine(makeUser("hello"));
391
+ writeLine(
392
+ makeAssistant("hi", {
393
+ input_tokens: 15000,
394
+ cache_creation_input_tokens: 0,
395
+ cache_read_input_tokens: 0,
396
+ output_tokens: 50,
397
+ }),
398
+ );
399
+
400
+ runHook({ prompt: "test" });
401
+
402
+ const sf = path.join(dataDir, "state-test-session-1234.json");
403
+ const state = JSON.parse(fs.readFileSync(sf, "utf8"));
404
+ assert.ok(state.recommendation.includes("Approaching"));
405
+ });
406
+ });
407
+
408
+ // =========================================================================
409
+ // 6. Empty/degenerate transcripts don't produce corrupt checkpoints
410
+ // =========================================================================
411
+ describe("degenerate transcript handling", () => {
412
+ it("transcript with only system messages produces no user/assistant content", () => {
413
+ writeLine({ type: "system", message: { content: "System prompt here" } });
414
+ writeLine({ type: "system", message: { content: "More system stuff" } });
415
+
416
+ const result = extractConversation(transcriptPath);
417
+ assert.ok(!result.includes("**User:**"));
418
+ assert.ok(!result.includes("**Assistant:**"));
419
+ });
420
+
421
+ it("transcript with only tool interactions produces header but no message body", () => {
422
+ writeLine(makeUser(""));
423
+ writeLine({
424
+ type: "assistant",
425
+ message: {
426
+ role: "assistant",
427
+ content: [
428
+ { type: "tool_use", id: "t1", name: "Read", input: { path: "/a" } },
429
+ { type: "tool_use", id: "t2", name: "Edit", input: { path: "/b" } },
430
+ ],
431
+ },
432
+ });
433
+ writeLine(makeUser(""));
434
+ writeLine({
435
+ type: "assistant",
436
+ message: {
437
+ role: "assistant",
438
+ content: [
439
+ { type: "tool_use", id: "t3", name: "Bash", input: { cmd: "ls" } },
440
+ ],
441
+ },
442
+ });
443
+
444
+ const result = extractConversation(transcriptPath);
445
+ // Empty user messages are skipped; tool-only assistants without a user exchange are omitted
446
+ assert.ok(
447
+ result.startsWith("## Session State"),
448
+ "Should have state header",
449
+ );
450
+ assert.ok(
451
+ result.includes("Tool operations:"),
452
+ "Should track tool operations",
453
+ );
454
+ assert.ok(
455
+ result.includes("Files modified:"),
456
+ "Should track files modified",
457
+ );
458
+ });
459
+
460
+ it("mixed tool and text preserves text and tracks files", () => {
461
+ writeLine(makeUser("fix the bug"));
462
+ writeLine({
463
+ type: "assistant",
464
+ message: {
465
+ role: "assistant",
466
+ content: [
467
+ { type: "text", text: "I'll fix that bug now." },
468
+ {
469
+ type: "tool_use",
470
+ id: "t1",
471
+ name: "Edit",
472
+ input: { path: "/bug.js" },
473
+ },
474
+ { type: "text", text: "Done, the bug is fixed." },
475
+ ],
476
+ },
477
+ });
478
+
479
+ const result = extractConversation(transcriptPath);
480
+ assert.ok(result.includes("fix the bug"));
481
+ assert.ok(result.includes("I'll fix that bug now."));
482
+ assert.ok(result.includes("Done, the bug is fixed."));
483
+ // File tracked in Session State header as "Files modified"
484
+ assert.ok(
485
+ result.includes("Files modified"),
486
+ "Should have Files modified in header",
487
+ );
488
+ assert.ok(result.includes("/bug.js"), "Should reference the edited file");
489
+ // Tool use produces a summary, not raw tool_use JSON
490
+ assert.ok(!result.includes('"type":"tool_use"'));
491
+ });
492
+ });
493
+
494
+ // =========================================================================
495
+ // 7. Concurrent session isolation
496
+ // =========================================================================
497
+ describe("session isolation", () => {
498
+ it("different session IDs write to different state files", () => {
499
+ writeLine(makeUser("hello"));
500
+ writeLine(makeAssistant("hi", HIGH_USAGE));
501
+
502
+ runHook({ prompt: "test", session_id: "session-AAA" });
503
+ runHook({ prompt: "test", session_id: "session-BBB" });
504
+
505
+ const stateA = path.join(dataDir, "state-session-AAA.json");
506
+ const stateB = path.join(dataDir, "state-session-BBB.json");
507
+ assert.ok(fs.existsSync(stateA));
508
+ assert.ok(fs.existsSync(stateB));
509
+
510
+ const dataA = JSON.parse(fs.readFileSync(stateA, "utf8"));
511
+ const dataB = JSON.parse(fs.readFileSync(stateB, "utf8"));
512
+ assert.equal(dataA.session_id, "session-AAA");
513
+ assert.equal(dataB.session_id, "session-BBB");
514
+ });
515
+ });
516
+
517
+ // =========================================================================
518
+ // capCheckpointContent and writeCompactionState — unit coverage
519
+ // =========================================================================
520
+
521
+ describe("capCheckpointContent", () => {
522
+ it("returns content unchanged when under limit", async () => {
523
+ const { capCheckpointContent } = await import("../lib/checkpoint.mjs");
524
+ const short = "Hello world";
525
+ assert.equal(capCheckpointContent(short, 200000), short);
526
+ });
527
+
528
+ it("trims content that exceeds the limit", async () => {
529
+ const { capCheckpointContent } = await import("../lib/checkpoint.mjs");
530
+ // maxTokens=100 → maxChars = max(50000, 300) = 50000
531
+ // Need content > 50000 chars
532
+ const big = "A".repeat(60000);
533
+ const result = capCheckpointContent(big, 100);
534
+ assert.ok(result.length < big.length);
535
+ assert.ok(result.includes("chars trimmed from middle"));
536
+ assert.ok(result.startsWith("AAAA"));
537
+ assert.ok(result.endsWith("AAAA"));
538
+ });
539
+
540
+ it("uses default maxTokens when not provided", async () => {
541
+ const { capCheckpointContent } = await import("../lib/checkpoint.mjs");
542
+ const content = "X".repeat(100);
543
+ assert.equal(capCheckpointContent(content), content);
544
+ });
545
+ });
546
+
547
+ describe("writeCompactionState", () => {
548
+ it("writes a state file with correct fields via performCompaction", () => {
549
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
550
+ writeLine(makeUser("test the state write"));
551
+ writeLine(makeAssistant("Done.", HIGH_USAGE));
552
+
553
+ const result = performCompaction({
554
+ mode: "smart",
555
+ transcriptPath,
556
+ sessionId: "test-wcs",
557
+ });
558
+ assert.ok(result, "Should produce a result");
559
+ assert.ok(result.stats.postTokens > 0, "Should have post-token count");
560
+ assert.ok(result.stats.saved >= 0, "Should compute savings");
561
+ });
562
+
563
+ it("performCompaction reads baseline_overhead from state file", () => {
564
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
565
+ // Write a state file with baseline_overhead before compacting
566
+ fs.writeFileSync(
567
+ path.join(dataDir, "state-test-bo.json"),
568
+ JSON.stringify({ baseline_overhead: 12000 }),
569
+ );
570
+ writeLine(makeUser("test baseline overhead"));
571
+ writeLine(makeAssistant("Done.", HIGH_USAGE));
572
+
573
+ const result = performCompaction({
574
+ mode: "smart",
575
+ transcriptPath,
576
+ sessionId: "test-bo",
577
+ });
578
+ assert.ok(result, "Should produce a result");
579
+ // The overhead should factor into post-token calculation
580
+ assert.ok(result.stats.postTokens > 0);
581
+ });
582
+ });
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import { describe, it } from "node:test";
4
+
5
+ // logger.mjs imports LOG_DIR and LOG_FILE from paths.mjs which reads
6
+ // CLAUDE_PLUGIN_DATA at import time. We can't easily redirect the log
7
+ // file, so we test via the real log file location. The log function is
8
+ // designed to never throw, so these tests verify behaviour + safety.
9
+
10
+ // We import once and test the singleton behaviour.
11
+ import { log } from "../lib/logger.mjs";
12
+ import { LOG_DIR, LOG_FILE } from "../lib/paths.mjs";
13
+
14
+ // ===========================================================================
15
+ // log()
16
+ // ===========================================================================
17
+
18
+ describe("log", () => {
19
+ it("writes a line to the log file", () => {
20
+ const marker = `test-marker-${Date.now()}-${Math.random()}`;
21
+ log(marker);
22
+ const content = fs.readFileSync(LOG_FILE, "utf8");
23
+ assert.ok(content.includes(marker));
24
+ });
25
+
26
+ it("prepends an ISO timestamp", () => {
27
+ const marker = `ts-check-${Date.now()}`;
28
+ log(marker);
29
+ const lines = fs.readFileSync(LOG_FILE, "utf8").split("\n");
30
+ const line = lines.find((l) => l.includes(marker));
31
+ assert.ok(line, "log line should exist");
32
+ // Format: [2025-01-01T00:00:00.000Z] message
33
+ assert.match(line, /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
34
+ });
35
+
36
+ it("appends a newline after each message", () => {
37
+ const marker = `newline-check-${Date.now()}`;
38
+ log(marker);
39
+ const content = fs.readFileSync(LOG_FILE, "utf8");
40
+ assert.ok(content.includes(`${marker}\n`));
41
+ });
42
+
43
+ it("creates LOG_DIR if it does not exist", () => {
44
+ // LOG_DIR should exist after calling log (it's created lazily)
45
+ log("dir-check");
46
+ assert.ok(fs.existsSync(LOG_DIR));
47
+ });
48
+
49
+ it("does not throw on empty message", () => {
50
+ log("");
51
+ });
52
+
53
+ it("does not throw on null message", () => {
54
+ log(null);
55
+ });
56
+
57
+ it("does not throw on undefined message", () => {
58
+ log(undefined);
59
+ });
60
+
61
+ it("does not throw on object message", () => {
62
+ log({ key: "value" });
63
+ });
64
+
65
+ it("handles multiple rapid calls without error", () => {
66
+ for (let i = 0; i < 50; i++) {
67
+ log(`rapid-${i}`);
68
+ }
69
+ });
70
+ });