squeezr-ai 1.46.2 → 1.80.6

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 (119) hide show
  1. package/README.md +189 -315
  2. package/bin/squeezr.js +2535 -2251
  3. package/dist/__tests__/aiRateLimit.test.d.ts +1 -0
  4. package/dist/__tests__/aiRateLimit.test.js +20 -0
  5. package/dist/__tests__/attachmentDedup.test.d.ts +1 -0
  6. package/dist/__tests__/attachmentDedup.test.js +89 -0
  7. package/dist/__tests__/compressibilityProbe.test.d.ts +1 -0
  8. package/dist/__tests__/compressibilityProbe.test.js +45 -0
  9. package/dist/__tests__/compressionGuard.test.d.ts +1 -0
  10. package/dist/__tests__/compressionGuard.test.js +57 -0
  11. package/dist/__tests__/compressor.test.js +104 -51
  12. package/dist/__tests__/diffRead.test.d.ts +1 -0
  13. package/dist/__tests__/diffRead.test.js +83 -0
  14. package/dist/__tests__/glossaryStore.test.d.ts +1 -0
  15. package/dist/__tests__/glossaryStore.test.js +37 -0
  16. package/dist/__tests__/glossarySub.test.d.ts +1 -0
  17. package/dist/__tests__/glossarySub.test.js +162 -0
  18. package/dist/__tests__/imageDedup.test.d.ts +1 -0
  19. package/dist/__tests__/imageDedup.test.js +80 -0
  20. package/dist/__tests__/largeBlock.test.d.ts +1 -0
  21. package/dist/__tests__/largeBlock.test.js +35 -0
  22. package/dist/__tests__/mcpFilter.test.d.ts +1 -0
  23. package/dist/__tests__/mcpFilter.test.js +87 -0
  24. package/dist/__tests__/newFeatures.test.d.ts +1 -0
  25. package/dist/__tests__/newFeatures.test.js +124 -0
  26. package/dist/__tests__/qualityHarness.test.d.ts +1 -0
  27. package/dist/__tests__/qualityHarness.test.js +98 -0
  28. package/dist/__tests__/rateLimitHeaders.test.js +6 -0
  29. package/dist/__tests__/requestCapture.test.d.ts +1 -0
  30. package/dist/__tests__/requestCapture.test.js +37 -0
  31. package/dist/__tests__/skillDedup.test.d.ts +1 -0
  32. package/dist/__tests__/skillDedup.test.js +57 -0
  33. package/dist/__tests__/staleTurns.test.d.ts +1 -0
  34. package/dist/__tests__/staleTurns.test.js +113 -0
  35. package/dist/__tests__/structuredGuard.test.d.ts +1 -0
  36. package/dist/__tests__/structuredGuard.test.js +72 -0
  37. package/dist/__tests__/toolDescComp.test.d.ts +1 -0
  38. package/dist/__tests__/toolDescComp.test.js +157 -0
  39. package/dist/__tests__/toolResultDedup.test.d.ts +1 -0
  40. package/dist/__tests__/toolResultDedup.test.js +40 -0
  41. package/dist/aiRateLimit.d.ts +19 -0
  42. package/dist/aiRateLimit.js +35 -0
  43. package/dist/aiToggle.d.ts +14 -0
  44. package/dist/aiToggle.js +53 -0
  45. package/dist/attachmentCompress.d.ts +9 -0
  46. package/dist/attachmentCompress.js +211 -0
  47. package/dist/attachmentDedup.d.ts +9 -0
  48. package/dist/attachmentDedup.js +89 -0
  49. package/dist/bypass.d.ts +6 -3
  50. package/dist/bypass.js +37 -5
  51. package/dist/cache.d.ts +3 -0
  52. package/dist/cache.js +10 -0
  53. package/dist/circuitBreaker.d.ts +4 -2
  54. package/dist/circuitBreaker.js +6 -3
  55. package/dist/compressibilityProbe.d.ts +8 -0
  56. package/dist/compressibilityProbe.js +47 -0
  57. package/dist/compressionGuard.d.ts +31 -0
  58. package/dist/compressionGuard.js +101 -0
  59. package/dist/compressor.d.ts +51 -1
  60. package/dist/compressor.js +599 -73
  61. package/dist/config.d.ts +21 -1
  62. package/dist/config.js +58 -2
  63. package/dist/dashboard.d.ts +3 -1
  64. package/dist/dashboard.js +2163 -1655
  65. package/dist/diffRead.d.ts +9 -0
  66. package/dist/diffRead.js +149 -0
  67. package/dist/expand.d.ts +2 -0
  68. package/dist/expand.js +6 -0
  69. package/dist/glossaryStore.d.ts +28 -0
  70. package/dist/glossaryStore.js +131 -0
  71. package/dist/glossarySub.d.ts +38 -0
  72. package/dist/glossarySub.js +123 -0
  73. package/dist/history.d.ts +35 -1
  74. package/dist/history.js +31 -5
  75. package/dist/identGlossary.d.ts +20 -0
  76. package/dist/identGlossary.js +215 -0
  77. package/dist/imageDedup.d.ts +12 -0
  78. package/dist/imageDedup.js +98 -0
  79. package/dist/index.js +7 -0
  80. package/dist/limits.d.ts +5 -2
  81. package/dist/limits.js +47 -4
  82. package/dist/logFeed.d.ts +10 -0
  83. package/dist/logFeed.js +42 -0
  84. package/dist/mcpFilter.d.ts +43 -0
  85. package/dist/mcpFilter.js +89 -0
  86. package/dist/mcpToolFilter.d.ts +32 -0
  87. package/dist/mcpToolFilter.js +140 -0
  88. package/dist/probePort.js +5 -1
  89. package/dist/promptCache.d.ts +44 -0
  90. package/dist/promptCache.js +121 -0
  91. package/dist/qualityGovernor.d.ts +11 -0
  92. package/dist/qualityGovernor.js +69 -0
  93. package/dist/requestCapture.d.ts +21 -0
  94. package/dist/requestCapture.js +79 -0
  95. package/dist/semanticRead.d.ts +9 -0
  96. package/dist/semanticRead.js +188 -0
  97. package/dist/server.js +1398 -992
  98. package/dist/sessionCache.js +9 -2
  99. package/dist/skillDedup.d.ts +5 -0
  100. package/dist/skillDedup.js +89 -0
  101. package/dist/staleTurnSummary.d.ts +9 -0
  102. package/dist/staleTurnSummary.js +110 -0
  103. package/dist/staleTurns.d.ts +14 -0
  104. package/dist/staleTurns.js +80 -0
  105. package/dist/stats.d.ts +16 -3
  106. package/dist/stats.js +157 -21
  107. package/dist/stockToolDescs.d.ts +12 -0
  108. package/dist/stockToolDescs.js +69 -0
  109. package/dist/structuredGuard.d.ts +25 -0
  110. package/dist/structuredGuard.js +116 -0
  111. package/dist/systemPrompt.js +6 -2
  112. package/dist/systemSectioning.d.ts +21 -0
  113. package/dist/systemSectioning.js +111 -0
  114. package/dist/toolDescComp.d.ts +30 -0
  115. package/dist/toolDescComp.js +81 -0
  116. package/dist/toolResultDedup.d.ts +9 -0
  117. package/dist/toolResultDedup.js +88 -0
  118. package/package.json +69 -66
  119. package/squeezr.toml +18 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { tryConsumeAiCall, aiCallsRemaining, _config } from '../aiRateLimit.js';
3
+ describe('aiRateLimit', () => {
4
+ it('allows up to MAX_CALLS_PER_WINDOW calls then blocks', () => {
5
+ // Fresh module state — consume the whole window
6
+ let allowed = 0;
7
+ for (let i = 0; i < _config.MAX_CALLS_PER_WINDOW + 10; i++) {
8
+ if (tryConsumeAiCall())
9
+ allowed++;
10
+ }
11
+ expect(allowed).toBe(_config.MAX_CALLS_PER_WINDOW);
12
+ });
13
+ it('reports 0 remaining once exhausted', () => {
14
+ // Window already exhausted by the previous test (same module instance)
15
+ expect(aiCallsRemaining()).toBe(0);
16
+ });
17
+ it('blocks further calls after exhaustion', () => {
18
+ expect(tryConsumeAiCall()).toBe(false);
19
+ });
20
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Tests for v1.49.0 attachment/artifact dedup.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { dedupAttachments } from '../attachmentDedup.js';
6
+ describe('attachmentDedup', () => {
7
+ it('dedupes large repeated text blocks', () => {
8
+ const big = 'X'.repeat(800);
9
+ const messages = [
10
+ { role: 'user', content: [{ type: 'text', text: big }] },
11
+ { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
12
+ { role: 'user', content: [{ type: 'text', text: big }] },
13
+ { role: 'assistant', content: [{ type: 'text', text: 'live answer' }] },
14
+ { role: 'user', content: [{ type: 'text', text: 'live ask' }] },
15
+ ];
16
+ const r = dedupAttachments(messages);
17
+ expect(r.dedupCount).toBeGreaterThanOrEqual(1);
18
+ expect(r.savedChars).toBeGreaterThan(0);
19
+ });
20
+ it('never touches the last user message (live ask)', () => {
21
+ const big = 'Y'.repeat(800);
22
+ const messages = [
23
+ { role: 'user', content: [{ type: 'text', text: big }] },
24
+ { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
25
+ { role: 'user', content: [{ type: 'text', text: big }] }, // LAST user — should be preserved
26
+ ];
27
+ dedupAttachments(messages);
28
+ expect(messages[2].content[0].text).toBe(big);
29
+ });
30
+ it('never touches the last assistant message (live answer)', () => {
31
+ const big = 'Z'.repeat(800);
32
+ const messages = [
33
+ { role: 'assistant', content: [{ type: 'text', text: big }] },
34
+ { role: 'user', content: [{ type: 'text', text: 'x' }] },
35
+ { role: 'assistant', content: [{ type: 'text', text: big }] }, // LAST assistant — preserved
36
+ { role: 'user', content: [{ type: 'text', text: 'follow up' }] },
37
+ ];
38
+ dedupAttachments(messages);
39
+ expect(messages[2].content[0].text).toBe(big);
40
+ });
41
+ it('does not replace blocks below MIN_BLOCK_CHARS threshold', () => {
42
+ const small = 'hi';
43
+ const messages = [
44
+ { role: 'user', content: [{ type: 'text', text: small }] },
45
+ { role: 'assistant', content: [{ type: 'text', text: 'ack' }] },
46
+ { role: 'user', content: [{ type: 'text', text: small }] },
47
+ { role: 'user', content: [{ type: 'text', text: 'live' }] },
48
+ ];
49
+ const r = dedupAttachments(messages);
50
+ expect(r.dedupCount).toBe(0);
51
+ });
52
+ it('never touches tool_use or tool_result blocks', () => {
53
+ const big = 'W'.repeat(800);
54
+ const messages = [
55
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x' } }] },
56
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: big }] },
57
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/x' } }] },
58
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: big }] },
59
+ { role: 'user', content: [{ type: 'text', text: 'live ask' }] },
60
+ ];
61
+ dedupAttachments(messages);
62
+ expect(messages[1].content[0].type).toBe('tool_result');
63
+ expect(messages[3].content[0].type).toBe('tool_result');
64
+ expect(messages[1].content[0].content).toBe(big);
65
+ });
66
+ it('keeps single-occurrence text blocks untouched', () => {
67
+ const messages = [
68
+ { role: 'user', content: [{ type: 'text', text: 'A'.repeat(800) }] },
69
+ { role: 'assistant', content: [{ type: 'text', text: 'B'.repeat(800) }] },
70
+ { role: 'user', content: [{ type: 'text', text: 'live' }] },
71
+ ];
72
+ const r = dedupAttachments(messages);
73
+ expect(r.dedupCount).toBe(0);
74
+ expect(messages[0].content[0].text).toBe('A'.repeat(800));
75
+ expect(messages[1].content[0].text).toBe('B'.repeat(800));
76
+ });
77
+ it('produces non-empty placeholder text (never empty content)', () => {
78
+ const big = 'D'.repeat(800);
79
+ const messages = [
80
+ { role: 'user', content: [{ type: 'text', text: big }] },
81
+ { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
82
+ { role: 'user', content: [{ type: 'text', text: big }] },
83
+ { role: 'user', content: [{ type: 'text', text: 'live' }] },
84
+ ];
85
+ dedupAttachments(messages);
86
+ expect(typeof messages[0].content[0].text).toBe('string');
87
+ expect(messages[0].content[0].text.length).toBeGreaterThan(20);
88
+ });
89
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { looksIncompressible, deflateRatio } from '../compressibilityProbe.js';
3
+ // Calibrated against real Zest output (see compressibilityProbe.ts):
4
+ // verbose log w/ repeated lines → deflate ~0.17 → Zest saved 56% → KEEP
5
+ // dense error/path list → deflate ~0.76 → Zest saved 0% → SKIP
6
+ // test output (mixed) → deflate ~0.63 → Zest saved 5% → SKIP
7
+ const VERBOSE = ('npm warn deprecated foo@1.0.0: use bar\n' +
8
+ 'npm warn deprecated foo@1.0.0: use bar\n' +
9
+ 'npm warn deprecated baz@2.0.0: no longer maintained\n' +
10
+ 'added 1242 packages, and audited 1243 packages in 14s\n' +
11
+ '201 packages are looking for funding\n' +
12
+ ' run `npm fund` for details\n' +
13
+ 'found 0 vulnerabilities\n' +
14
+ 'gardening node_modules ... done\n' +
15
+ 'gardening node_modules ... done\n' +
16
+ 'gardening node_modules ... done').repeat(3);
17
+ const DENSE_PATHS = ('src/compressor.ts:268 error TS2322: Type string is not assignable\n' +
18
+ 'src/dashboard.ts:1027 warning unused var aiSavedTok\n' +
19
+ 'src/server.ts:956 note: see https://docs.foo.com/E1234 for details\n' +
20
+ 'src/stats.ts:361 error ENOENT no such file\n' +
21
+ 'src/cache.ts:42 error TS2304: cannot find name foo\n' +
22
+ 'src/expand.ts:88 warning deprecated symbol bar used here\n' +
23
+ 'src/index.ts:12 error TS1005: semicolon expected near token');
24
+ describe('looksIncompressible', () => {
25
+ it('keeps redundant/verbose blocks (AI will compress them well)', () => {
26
+ expect(looksIncompressible(VERBOSE)).toBe(false);
27
+ });
28
+ it('skips dense path/error dumps (AI would be rejected — wasted call)', () => {
29
+ expect(looksIncompressible(DENSE_PATHS)).toBe(true);
30
+ });
31
+ it('does not probe tiny blocks (deflate ratio is noisy there)', () => {
32
+ expect(looksIncompressible('error: ENOENT')).toBe(false);
33
+ });
34
+ it('respects a custom maxDeflate threshold', () => {
35
+ // VERBOSE deflates to ~0.17; a 0.1 threshold flips it to "incompressible".
36
+ expect(looksIncompressible(VERBOSE, 0.1)).toBe(true);
37
+ });
38
+ it('deflateRatio is in (0,1] and lower for redundant text', () => {
39
+ const rVerbose = deflateRatio(VERBOSE);
40
+ const rDense = deflateRatio(DENSE_PATHS);
41
+ expect(rVerbose).toBeGreaterThan(0);
42
+ expect(rVerbose).toBeLessThan(rDense);
43
+ expect(rDense).toBeLessThanOrEqual(1);
44
+ });
45
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateCompression } from '../compressionGuard.js';
3
+ describe('validateCompression', () => {
4
+ it('accepts a good compression that keeps key tokens and saves enough', () => {
5
+ const original = 'Running tests in src/auth/login.ts\n' +
6
+ 'PASS 42 tests, FAIL 1: expected 200 got 401 at line 88\n' +
7
+ 'See https://example.com/docs/errors for details\n' +
8
+ 'lots of incidental filler text repeated over and over to pad the block length so the ratio is meaningful '.repeat(8);
9
+ const compressed = 'FAIL src/auth/login.ts line 88: expected 200 got 401. See https://example.com/docs/errors';
10
+ const r = validateCompression(original, compressed);
11
+ expect(r.accept).toBe(true);
12
+ expect(r.ratio).toBeGreaterThan(0.15);
13
+ });
14
+ it('rejects when the result is LONGER than the original (negative savings)', () => {
15
+ const original = 'short output';
16
+ const compressed = 'this compressed result is actually much longer than the original input text';
17
+ const r = validateCompression(original, compressed);
18
+ expect(r.accept).toBe(false);
19
+ expect(r.reason).toMatch(/ratio/);
20
+ });
21
+ it('rejects empty output', () => {
22
+ const r = validateCompression('some real content '.repeat(20), ' ');
23
+ expect(r.accept).toBe(false);
24
+ expect(r.reason).toBe('empty output');
25
+ });
26
+ it('rejects when a critical file path is dropped', () => {
27
+ const original = 'Error in src/payments/checkout.ts at line 12\n' +
28
+ 'stack trace filler '.repeat(40);
29
+ const compressed = 'Error at line 12'; // dropped the path
30
+ const r = validateCompression(original, compressed);
31
+ expect(r.accept).toBe(false);
32
+ expect(r.reason).toMatch(/critical token/);
33
+ });
34
+ it('rejects when an error code is dropped', () => {
35
+ const original = 'connection failed ECONNREFUSED on attempt 3\n' + 'retry filler '.repeat(40);
36
+ const compressed = 'connection failed on attempt 3'; // dropped ECONNREFUSED
37
+ const r = validateCompression(original, compressed);
38
+ expect(r.accept).toBe(false);
39
+ expect(r.reason).toMatch(/critical token/);
40
+ });
41
+ it('rejects when an URL is dropped', () => {
42
+ const original = 'fetch https://api.service.com/v2/users returned 500\n' + 'body filler '.repeat(40);
43
+ const compressed = 'fetch returned 500'; // dropped URL (and 500 is HTTP code, also hard)
44
+ const r = validateCompression(original, compressed);
45
+ expect(r.accept).toBe(false);
46
+ });
47
+ it('tolerates dropping a few incidental soft tokens', () => {
48
+ const original = 'function computeTotals iterates items and calls helperOne helperTwo helperThree\n' +
49
+ 'verbose explanation filler text '.repeat(30);
50
+ // keeps the main identifier, drops a couple of minor helpers — within tolerance
51
+ const compressed = 'computeTotals iterates items, calls helpers';
52
+ const r = validateCompression(original, compressed, { minRatio: 0.1 });
53
+ // soft tolerance is small, so this MAY reject; assert the function runs and returns a ratio
54
+ expect(typeof r.accept).toBe('boolean');
55
+ expect(r.ratio).toBeGreaterThan(0);
56
+ });
57
+ });
@@ -3,24 +3,36 @@ import { clearExpandStore } from '../expand.js';
3
3
  import { clearSessionCache } from '../sessionCache.js';
4
4
  // Mock AI SDKs before importing compressor
5
5
  vi.mock('@anthropic-ai/sdk', () => ({
6
- default: vi.fn().mockImplementation(() => ({
7
- messages: {
8
- create: vi.fn().mockResolvedValue({
9
- content: [{ text: 'AI compressed summary' }],
10
- }),
11
- },
12
- })),
13
- }));
14
- vi.mock('openai', () => ({
15
- default: vi.fn().mockImplementation(() => ({
16
- chat: {
17
- completions: {
6
+ // function (not arrow) — `new Anthropic()` requires a constructable implementation
7
+ default: vi.fn().mockImplementation(function () {
8
+ return {
9
+ messages: {
18
10
  create: vi.fn().mockResolvedValue({
19
- choices: [{ message: { content: 'AI compressed summary' } }],
11
+ content: [{ text: 'AI compressed summary' }],
20
12
  }),
21
13
  },
22
- },
23
- })),
14
+ };
15
+ }),
16
+ }));
17
+ vi.mock('openai', () => ({
18
+ default: vi.fn().mockImplementation(function () {
19
+ return {
20
+ chat: {
21
+ completions: {
22
+ create: vi.fn().mockResolvedValue({
23
+ choices: [{ message: { content: 'AI compressed summary' } }],
24
+ }),
25
+ },
26
+ },
27
+ };
28
+ }),
29
+ }));
30
+ // Force the AI compression master toggle ON for these tests (production default
31
+ // is off + persisted to disk; tests must not depend on the user's local state).
32
+ vi.mock('../aiToggle.js', () => ({
33
+ isAiCompressionEnabled: () => true,
34
+ setAiCompression: () => { },
35
+ toggleAiCompression: () => true,
24
36
  }));
25
37
  // Mock fetch for Gemini
26
38
  const mockFetch = vi.fn().mockResolvedValue({
@@ -50,6 +62,11 @@ const baseConfig = {
50
62
  shouldSkipTool: () => false,
51
63
  skipTools: new Set(),
52
64
  onlyTools: new Set(),
65
+ aiSkipTools: new Set(),
66
+ aiCompression: true, // tests exercise the AI path; production default is false
67
+ compressConversation: false,
68
+ keepRecentAssistant: 3,
69
+ assistantThreshold: 300,
53
70
  };
54
71
  beforeEach(() => {
55
72
  clearExpandStore();
@@ -89,9 +106,8 @@ describe('compressAnthropicMessages', () => {
89
106
  expect(block.content).not.toContain('[squeezr:');
90
107
  });
91
108
  it('compresses old blocks beyond keepRecent', async () => {
92
- const longText = 'x'.repeat(200);
93
- // 2 messages: first is old, second is recent
94
- const msgs = makeMessages([longText, longText]);
109
+ // distinct texts — identical blocks would be collapsed by cross-turn dedup first
110
+ const msgs = makeMessages(['x'.repeat(1600), 'y'.repeat(1600)]);
95
111
  const [result, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
96
112
  // First block should be compressed
97
113
  const firstBlock = result[1].content[0];
@@ -99,8 +115,7 @@ describe('compressAnthropicMessages', () => {
99
115
  expect(savings.compressed).toBe(1);
100
116
  });
101
117
  it('embeds squeezr ID and ratio in compressed content', async () => {
102
- const longText = 'x'.repeat(200);
103
- const msgs = makeMessages([longText, longText]);
118
+ const msgs = makeMessages(['x'.repeat(1600), 'y'.repeat(1600)]);
104
119
  const [result] = await compressAnthropicMessages(msgs, 'key', baseConfig);
105
120
  const compressed = result[1].content[0].content;
106
121
  expect(compressed).toMatch(/\[squeezr:[a-f0-9]{6} -\d+%\]/);
@@ -112,8 +127,7 @@ describe('compressAnthropicMessages', () => {
112
127
  expect(savings.compressed).toBe(0);
113
128
  });
114
129
  it('returns dry-run savings without modifying messages', async () => {
115
- const longText = 'x'.repeat(200);
116
- const msgs = makeMessages([longText, longText]);
130
+ const msgs = makeMessages(['x'.repeat(1600), 'y'.repeat(1600)]);
117
131
  const [result, savings] = await compressAnthropicMessages(msgs, 'key', { ...baseConfig, dryRun: true });
118
132
  expect(savings.dryRun).toBe(true);
119
133
  // Messages should not be modified
@@ -122,8 +136,7 @@ describe('compressAnthropicMessages', () => {
122
136
  });
123
137
  it('uses session cache on second call with same content', async () => {
124
138
  const Anthropic = (await import('@anthropic-ai/sdk')).default;
125
- const longText = 'x'.repeat(200);
126
- const msgs = makeMessages([longText, longText]);
139
+ const msgs = makeMessages(['x'.repeat(200), 'y'.repeat(200)]);
127
140
  // First call — compresses
128
141
  await compressAnthropicMessages(msgs, 'key', baseConfig);
129
142
  const callsAfterFirst = Anthropic.mock.results[0]?.value?.messages?.create?.mock?.calls?.length ?? 0;
@@ -143,13 +156,56 @@ describe('compressAnthropicMessages', () => {
143
156
  expect(content).not.toContain('context4');
144
157
  });
145
158
  it('tracks savings correctly', async () => {
146
- const longText = 'x'.repeat(500);
147
- const msgs = makeMessages([longText, longText]);
159
+ const msgs = makeMessages(['x'.repeat(1600), 'y'.repeat(1600)]);
148
160
  const [, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
149
161
  expect(savings.savedChars).toBeGreaterThan(0);
150
162
  expect(savings.originalChars).toBeGreaterThan(0);
151
163
  expect(savings.byTool.length).toBeGreaterThan(0);
152
164
  });
165
+ it('NEVER AI-compresses tool results at or before the cache_control barrier', async () => {
166
+ // AI compression is not byte-stable → would invalidate the prompt cache.
167
+ // A block under (or before) the last cache_control marker must never get an
168
+ // [squeezr:ID] AI placeholder. Deterministic cleanup MAY touch it (it's stable).
169
+ const oldText = 'old line\n'.repeat(50); // compressible (dup lines) but cached
170
+ const newText = 'new line\n'.repeat(50);
171
+ const msgs = [
172
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't0', name: 'Bash' }] },
173
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't0', content: oldText, cache_control: { type: 'ephemeral' } }] },
174
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash' }] },
175
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: newText }] },
176
+ ];
177
+ const [result, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
178
+ // Cached block must NOT have an AI placeholder, and cache_control must survive.
179
+ expect(String(result[1].content[0].content)).not.toContain('[squeezr:');
180
+ expect(result[1].content[0].cache_control).toEqual({ type: 'ephemeral' });
181
+ // AI compression count is 0 here: the only old block is the cached one (skipped),
182
+ // the new block is within keepRecent.
183
+ expect(savings.compressed).toBe(0);
184
+ });
185
+ it('deterministic cleanup IS allowed on the cached prefix (it is byte-stable)', async () => {
186
+ // A block with duplicate lines under cache_control: det dedup may shrink it,
187
+ // but the result is identical every request → cache stays valid.
188
+ const dupText = 'same\n'.repeat(60); // dedup-able by deterministic pass
189
+ const msgs = [
190
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't0', name: 'Bash' }] },
191
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't0', content: dupText, cache_control: { type: 'ephemeral' } }] },
192
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash' }] },
193
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'tail\n'.repeat(60) }] },
194
+ ];
195
+ // Run twice — the cached block's output must be byte-identical (stable).
196
+ const [r1] = await compressAnthropicMessages(msgs, 'key', baseConfig);
197
+ const [r2] = await compressAnthropicMessages(msgs, 'key', baseConfig);
198
+ const out1 = String(r1[1].content[0].content);
199
+ const out2 = String(r2[1].content[0].content);
200
+ expect(out1).toBe(out2); // stable between requests → cache-safe
201
+ expect(r1[1].content[0].cache_control).toEqual({ type: 'ephemeral' });
202
+ });
203
+ it('compresses freely when there is no cache_control marker', async () => {
204
+ const msgs = makeMessages(['x'.repeat(1600), 'y'.repeat(1600)]);
205
+ const [, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
206
+ // No barrier → old block is eligible for AI compression
207
+ expect(savings.compressed).toBe(1);
208
+ });
153
209
  });
154
210
  // ── OpenAI format ─────────────────────────────────────────────────────────────
155
211
  describe('compressOpenAIMessages', () => {
@@ -172,16 +228,14 @@ describe('compressOpenAIMessages', () => {
172
228
  expect(result).toEqual(msgs);
173
229
  });
174
230
  it('compresses old tool messages', async () => {
175
- const longText = 'y'.repeat(200);
176
- const msgs = makeMessages([longText, longText]);
231
+ const msgs = makeMessages(['y'.repeat(200), 'w'.repeat(200)]);
177
232
  const [result, savings] = await compressOpenAIMessages(msgs, 'key', baseConfig);
178
233
  expect(result[1].content).toContain('[squeezr:');
179
234
  expect(savings.compressed).toBe(1);
180
235
  });
181
236
  it('uses Ollama backend for local keys', async () => {
182
237
  const OpenAI = (await import('openai')).default;
183
- const longText = 'z'.repeat(200);
184
- const msgs = makeMessages([longText, longText]);
238
+ const msgs = makeMessages(['z'.repeat(200), 'v'.repeat(200)]);
185
239
  await compressOpenAIMessages(msgs, 'ollama-key', { ...baseConfig, isLocalKey: () => true }, true);
186
240
  // OpenAI client should be called (Ollama uses OpenAI-compatible API)
187
241
  expect(OpenAI).toHaveBeenCalled();
@@ -194,11 +248,11 @@ describe('compressOpenAIMessages', () => {
194
248
  expect(result).toBeDefined();
195
249
  });
196
250
  it('returns dry-run without modifications', async () => {
197
- const longText = 'z'.repeat(200);
198
- const msgs = makeMessages([longText, longText]);
251
+ const oldText = 'z'.repeat(200);
252
+ const msgs = makeMessages([oldText, 'v'.repeat(200)]);
199
253
  const [result, savings] = await compressOpenAIMessages(msgs, 'key', { ...baseConfig, dryRun: true });
200
254
  expect(savings.dryRun).toBe(true);
201
- expect(result[1].content).toBe(longText);
255
+ expect(result[1].content).toBe(oldText);
202
256
  });
203
257
  });
204
258
  // ── Gemini format ─────────────────────────────────────────────────────────────
@@ -220,76 +274,75 @@ describe('compressGeminiContents', () => {
220
274
  expect(result).toEqual(cts);
221
275
  });
222
276
  it('compresses old function responses', async () => {
223
- const longText = 'g'.repeat(200);
224
- const cts = makeContents([longText, longText]);
277
+ const cts = makeContents(['g'.repeat(200), 'h'.repeat(200)]);
225
278
  const [result, savings] = await compressGeminiContents(cts, 'key', baseConfig);
226
279
  const response = result[1].parts[0].functionResponse.response;
227
280
  expect(JSON.stringify(response)).toContain('[squeezr:');
228
281
  expect(savings.compressed).toBe(1);
229
282
  });
230
283
  it('uses fetch with Gemini API URL', async () => {
231
- const longText = 'g'.repeat(200);
232
- const cts = makeContents([longText, longText]);
284
+ const cts = makeContents(['g'.repeat(200), 'h'.repeat(200)]);
233
285
  await compressGeminiContents(cts, 'my-google-key', baseConfig);
234
286
  expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('generativelanguage.googleapis.com'), expect.any(Object));
235
287
  expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('my-google-key'), expect.any(Object));
236
288
  });
237
289
  it('returns dry-run without modifications', async () => {
238
- const longText = 'g'.repeat(200);
239
- const cts = makeContents([longText, longText]);
290
+ const oldText = 'g'.repeat(200);
291
+ const cts = makeContents([oldText, 'h'.repeat(200)]);
240
292
  const [result, savings] = await compressGeminiContents(cts, 'key', { ...baseConfig, dryRun: true });
241
293
  expect(savings.dryRun).toBe(true);
242
294
  const response = result[1].parts[0].functionResponse.response;
243
- expect(response).toBe(longText);
295
+ expect(response).toBe(oldText);
244
296
  });
245
297
  });
246
298
  // ── skip_tools / only_tools / squeezr:skip ────────────────────────────────────
247
299
  describe('skip_tools and squeezr:skip', () => {
248
- function makeMessages(toolName, text) {
300
+ // Per-block texts — identical blocks would be collapsed by cross-turn dedup
301
+ function makeMessages(toolName, textOld, textRecent) {
249
302
  return [
250
303
  { role: 'assistant', content: [{ type: 'tool_use', id: 'tool_0', name: toolName }] },
251
- { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_0', content: text }] },
304
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_0', content: textOld }] },
252
305
  { role: 'assistant', content: [{ type: 'tool_use', id: 'tool_1', name: toolName }] },
253
- { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: text }] },
306
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: textRecent }] },
254
307
  ];
255
308
  }
256
309
  it('skips tool when shouldSkipTool returns true', async () => {
257
310
  const longText = 'x'.repeat(200);
258
- const msgs = makeMessages('Read', longText);
311
+ const msgs = makeMessages('Read', longText, 'y'.repeat(200));
259
312
  const skipConfig = { ...baseConfig, shouldSkipTool: (t) => t.toLowerCase() === 'read' };
260
313
  const [result, savings] = await compressAnthropicMessages(msgs, 'key', skipConfig);
261
314
  expect(savings.compressed).toBe(0);
262
315
  expect(result[1].content[0].content).toBe(longText);
263
316
  });
264
317
  it('compresses tool when shouldSkipTool returns false', async () => {
265
- const longText = 'x'.repeat(200);
266
- const msgs = makeMessages('Bash', longText);
318
+ const msgs = makeMessages('Bash', 'x'.repeat(1600), 'y'.repeat(1600));
267
319
  const [, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
268
320
  expect(savings.compressed).toBe(1);
269
321
  });
270
322
  it('respects squeezr:skip inline marker — does not compress that block', async () => {
271
- const longText = 'x'.repeat(200);
323
+ // Unique text per block — identical blocks would be collapsed by cross-turn dedup
324
+ const skipText = 'x'.repeat(1600);
272
325
  // 3 tool calls: tool_0 (skip marker), tool_1 (old, compressible), tool_2 (recent, kept)
273
326
  const msgs = [
274
327
  {
275
328
  role: 'assistant',
276
329
  content: [{ type: 'tool_use', id: 'tool_0', name: 'Bash', input: { command: 'git diff HEAD~3 # squeezr:skip' } }],
277
330
  },
278
- { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_0', content: longText }] },
331
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_0', content: skipText }] },
279
332
  {
280
333
  role: 'assistant',
281
334
  content: [{ type: 'tool_use', id: 'tool_1', name: 'Bash', input: { command: 'some other command' } }],
282
335
  },
283
- { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: longText }] },
336
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_1', content: 'y'.repeat(1600) }] },
284
337
  {
285
338
  role: 'assistant',
286
339
  content: [{ type: 'tool_use', id: 'tool_2', name: 'Bash', input: { command: 'another command' } }],
287
340
  },
288
- { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_2', content: longText }] },
341
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool_2', content: 'z'.repeat(1600) }] },
289
342
  ];
290
343
  const [result, savings] = await compressAnthropicMessages(msgs, 'key', baseConfig);
291
344
  // tool_0 has squeezr:skip → not compressed
292
- expect(result[1].content[0].content).toBe(longText);
345
+ expect(result[1].content[0].content).toBe(skipText);
293
346
  // tool_1 is old and not skipped → compressed
294
347
  expect(savings.compressed).toBe(1);
295
348
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compressRepeatedReads } from '../diffRead.js';
3
+ describe('diffRead', () => {
4
+ it('collapses two Reads of same path with small diff', () => {
5
+ const fileV1 = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n');
6
+ const fileV2 = fileV1.replace('line 5', 'line 5 modified');
7
+ const messages = [
8
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/foo.py' } }] },
9
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: fileV1 }] },
10
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/foo.py' } }] },
11
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: fileV2 }] },
12
+ ];
13
+ const r = compressRepeatedReads(messages);
14
+ expect(r.collapsedCount).toBe(1);
15
+ expect(r.savedChars).toBeGreaterThan(0);
16
+ const firstResult = messages[1].content[0].content;
17
+ expect(typeof firstResult).toBe('string');
18
+ expect(firstResult).toContain('squeezr_expand');
19
+ expect(messages[3].content[0].content).toBe(fileV2);
20
+ });
21
+ it('does NOT touch single Read', () => {
22
+ const content = 'X'.repeat(2000);
23
+ const messages = [
24
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/single.py' } }] },
25
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content }] },
26
+ ];
27
+ const r = compressRepeatedReads(messages);
28
+ expect(r.collapsedCount).toBe(0);
29
+ expect(messages[1].content[0].content).toBe(content);
30
+ });
31
+ it('preserves tool_use ids and structure', () => {
32
+ const fileV1 = 'a\n'.repeat(300);
33
+ const fileV2 = fileV1 + 'extra\n';
34
+ const messages = [
35
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'tA', name: 'Read', input: { file_path: '/y.py' } }] },
36
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tA', content: fileV1 }] },
37
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'tB', name: 'Read', input: { file_path: '/y.py' } }] },
38
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tB', content: fileV2 }] },
39
+ ];
40
+ compressRepeatedReads(messages);
41
+ expect(messages[0].content[0].id).toBe('tA');
42
+ expect(messages[0].content[0].name).toBe('Read');
43
+ expect(messages[2].content[0].id).toBe('tB');
44
+ expect(messages[1].content[0].tool_use_id).toBe('tA');
45
+ expect(messages[3].content[0].tool_use_id).toBe('tB');
46
+ });
47
+ it('skips reads with identical content', () => {
48
+ const same = 'identical\n'.repeat(100);
49
+ const messages = [
50
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/x.py' } }] },
51
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: same }] },
52
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/x.py' } }] },
53
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: same }] },
54
+ ];
55
+ const r = compressRepeatedReads(messages);
56
+ expect(r.collapsedCount).toBe(0);
57
+ });
58
+ it('ignores reads of different file paths', () => {
59
+ const text = 'L\n'.repeat(300);
60
+ const messages = [
61
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/a.py' } }] },
62
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: text }] },
63
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/b.py' } }] },
64
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: text }] },
65
+ ];
66
+ const r = compressRepeatedReads(messages);
67
+ expect(r.collapsedCount).toBe(0);
68
+ });
69
+ it('falls back to reference placeholder when diff would be too big', () => {
70
+ const fileV1 = Array.from({ length: 100 }, (_, i) => `line ${i} original`).join('\n');
71
+ const fileV2 = Array.from({ length: 100 }, (_, i) => `line ${i} totally different`).join('\n');
72
+ const messages = [
73
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/z.py' } }] },
74
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: fileV1 }] },
75
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/z.py' } }] },
76
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: fileV2 }] },
77
+ ];
78
+ const r = compressRepeatedReads(messages);
79
+ expect(r.collapsedCount).toBe(1);
80
+ const placeholder = messages[1].content[0].content;
81
+ expect(placeholder).toContain('squeezr_expand');
82
+ });
83
+ });
@@ -0,0 +1 @@
1
+ export {};