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,37 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { updateGlossaryFromRequest, loadGlossary, _internal } from '../glossaryStore.js';
3
+ describe('glossaryStore', () => {
4
+ it('never mutates the input messages', () => {
5
+ const messages = [
6
+ { role: 'user', content: '/long/path/here/file.py '.repeat(25) },
7
+ { role: 'assistant', content: 'ok' },
8
+ ];
9
+ const original = JSON.stringify(messages);
10
+ updateGlossaryFromRequest(messages);
11
+ expect(JSON.stringify(messages)).toBe(original);
12
+ });
13
+ it('does not assign refs below MIN_OCCURRENCES threshold', () => {
14
+ const messages = [
15
+ { role: 'user', content: '/some/path/file.py ' },
16
+ ];
17
+ const result = updateGlossaryFromRequest(messages);
18
+ expect(result.newRefs).toBe(0);
19
+ });
20
+ it('countOccurrences returns correct counts', () => {
21
+ const messages = [
22
+ { role: 'user', content: '/long/path/project/src/component.tsx '.repeat(25) },
23
+ ];
24
+ const counts = _internal.countOccurrences(messages, /(?:[A-Za-z]:[\\/][^\s'"<>|()\[\]{}]+|(?:\/|~\/|\.\/|\.\.\/)[^\s'"<>|()\[\]{}]{20,})/g);
25
+ const path = '/long/path/project/src/component.tsx';
26
+ expect(counts.get(path)).toBe(25);
27
+ });
28
+ it('assigns sequential $Pn refs', () => {
29
+ const g = loadGlossary();
30
+ const start = g.nextId;
31
+ // All refs must follow the $P prefix pattern
32
+ for (const ref of Object.values(g.mapping)) {
33
+ expect(ref).toMatch(/^\$P\d+$/);
34
+ }
35
+ expect(start).toBeGreaterThanOrEqual(1);
36
+ });
37
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { applyGlossarySubstitution, buildGlossaryLegend } from '../glossarySub.js';
3
+ // Token long enough to pass MIN_TOKEN_CHARS (30)
4
+ const TOKEN_A = '/home/user/Documents/MyProject/src/components/Button.tsx';
5
+ const TOKEN_B = '/home/user/Documents/MyProject/src/services/AuthService.ts';
6
+ const REF_A = '$P1';
7
+ const REF_B = '$P2';
8
+ const MAPPING = { [TOKEN_A]: REF_A, [TOKEN_B]: REF_B };
9
+ function msg(role, text) {
10
+ return { role, content: text };
11
+ }
12
+ function msgArr(role, ...texts) {
13
+ return { role, content: texts.map(t => ({ type: 'text', text: t })) };
14
+ }
15
+ // Repeat a message N times to pass the MIN_APPLY_OCCURRENCES (3) threshold
16
+ function repeat(text, n) {
17
+ return (text + ' ').repeat(n);
18
+ }
19
+ describe('applyGlossarySubstitution', () => {
20
+ it('no-op when mapping is empty', () => {
21
+ const msgs = [msg('user', repeat(TOKEN_A, 5)), msg('assistant', 'ok')];
22
+ const r = applyGlossarySubstitution(msgs, {});
23
+ expect(r.savedChars).toBe(0);
24
+ expect(r.netSavedChars).toBe(0);
25
+ expect(r.refsApplied.size).toBe(0);
26
+ });
27
+ it('no-op when token appears < MIN_APPLY_OCCURRENCES times', () => {
28
+ const msgs = [
29
+ msg('user', TOKEN_A), // 1 occurrence
30
+ msg('assistant', TOKEN_A), // 2 occurrences total
31
+ msg('user', 'final ask'), // last user — skipped
32
+ ];
33
+ const r = applyGlossarySubstitution(msgs, MAPPING);
34
+ expect(r.refsApplied.size).toBe(0);
35
+ });
36
+ it('replaces when token appears >= MIN_APPLY_OCCURRENCES times', () => {
37
+ const msgs = [
38
+ msg('user', repeat(TOKEN_A, 3)),
39
+ msg('assistant', `I modified ${TOKEN_A}`),
40
+ msg('user', 'what next?'), // last user — skipped
41
+ ];
42
+ const r = applyGlossarySubstitution(msgs, MAPPING);
43
+ expect(r.refsApplied.has(TOKEN_A)).toBe(true);
44
+ expect(r.savedChars).toBeGreaterThan(0);
45
+ });
46
+ it('never modifies the last user message', () => {
47
+ const lastAsk = `check ${TOKEN_A} please`;
48
+ const msgs = [
49
+ msg('user', repeat(TOKEN_A, 3)),
50
+ msg('assistant', TOKEN_A),
51
+ msg('user', lastAsk),
52
+ ];
53
+ applyGlossarySubstitution(msgs, MAPPING);
54
+ expect(msgs[2].content).toBe(lastAsk);
55
+ });
56
+ it('replaces in assistant string content', () => {
57
+ const msgs = [
58
+ msg('user', repeat(TOKEN_A, 3)),
59
+ msg('assistant', `The file is ${TOKEN_A}`),
60
+ msg('user', 'ok'),
61
+ ];
62
+ applyGlossarySubstitution(msgs, MAPPING);
63
+ expect(msgs[1].content).toContain(REF_A);
64
+ expect(msgs[1].content).not.toContain(TOKEN_A);
65
+ });
66
+ it('replaces in assistant array text blocks', () => {
67
+ const msgs = [
68
+ msg('user', repeat(TOKEN_A, 3)),
69
+ msgArr('assistant', `The file is ${TOKEN_A} and also ${TOKEN_A}`),
70
+ msg('user', 'ok'),
71
+ ];
72
+ applyGlossarySubstitution(msgs, MAPPING);
73
+ const block = (msgs[1].content)[0];
74
+ expect(block.text).toContain(REF_A);
75
+ expect(block.text).not.toContain(TOKEN_A);
76
+ });
77
+ it('never touches tool_use blocks inside assistant messages', () => {
78
+ const msgs = [
79
+ msg('user', repeat(TOKEN_A, 3)),
80
+ {
81
+ role: 'assistant',
82
+ content: [
83
+ { type: 'tool_use', id: 'tu_1', name: 'Read', input: { file_path: TOKEN_A } },
84
+ { type: 'text', text: `reading ${TOKEN_A}` },
85
+ ],
86
+ },
87
+ msg('user', 'done'),
88
+ ];
89
+ applyGlossarySubstitution(msgs, MAPPING);
90
+ const blocks = msgs[1].content;
91
+ // tool_use input must be untouched
92
+ expect(blocks[0].input?.file_path).toBe(TOKEN_A);
93
+ // text block is replaced
94
+ expect(blocks[1].text).toContain(REF_A);
95
+ });
96
+ it('never touches tool_result content inside user messages', () => {
97
+ const msgs = [
98
+ {
99
+ role: 'user',
100
+ content: [
101
+ { type: 'tool_result', tool_use_id: 'tu_1', content: `output of ${TOKEN_A}` },
102
+ ],
103
+ },
104
+ msg('assistant', repeat(TOKEN_A, 3)),
105
+ msg('user', 'next'),
106
+ ];
107
+ applyGlossarySubstitution(msgs, MAPPING);
108
+ const block = msgs[0].content[0];
109
+ expect(block.content).toContain(TOKEN_A); // untouched
110
+ });
111
+ it('replaces longer tokens before shorter (avoids partial match)', () => {
112
+ const LONG = '/home/user/Documents/MyProject/src/veryLongComponentName.tsx';
113
+ const SHORT = '/home/user/Documents/MyProject/src';
114
+ const mapping = { [LONG]: '$P1', [SHORT]: '$P2' };
115
+ const text = repeat(`${LONG} ${SHORT}`, 3);
116
+ const msgs = [msg('user', text), msg('assistant', text), msg('user', 'ok')];
117
+ applyGlossarySubstitution(msgs, mapping);
118
+ const result = msgs[1].content;
119
+ // $P1 should appear (long was replaced), not have $P2 inside what was $P1
120
+ expect(result).toContain('$P1');
121
+ expect(result).toContain('$P2');
122
+ // $P2 should NOT appear inside $P1 replacement (i.e., no "$P2/veryLong...")
123
+ expect(result).not.toContain('$P2/veryLong');
124
+ });
125
+ it('handles empty messages array gracefully', () => {
126
+ const r = applyGlossarySubstitution([], MAPPING);
127
+ expect(r.savedChars).toBe(0);
128
+ });
129
+ it('netSavedChars accounts for legend overhead', () => {
130
+ const msgs = [
131
+ msg('user', repeat(TOKEN_A, 5)),
132
+ msg('assistant', repeat(TOKEN_A, 5)),
133
+ msg('user', 'done'),
134
+ ];
135
+ const r = applyGlossarySubstitution(msgs, MAPPING);
136
+ expect(r.netSavedChars).toBe(r.savedChars - r.legendChars);
137
+ expect(r.legendChars).toBeGreaterThan(0);
138
+ });
139
+ it('replaces multiple refs in same message', () => {
140
+ const text = repeat(`${TOKEN_A} and ${TOKEN_B}`, 3);
141
+ const msgs = [msg('user', text), msg('assistant', text), msg('user', 'ok')];
142
+ const r = applyGlossarySubstitution(msgs, MAPPING);
143
+ expect(r.refsApplied.size).toBe(2);
144
+ const content = msgs[1].content;
145
+ expect(content).toContain(REF_A);
146
+ expect(content).toContain(REF_B);
147
+ });
148
+ });
149
+ describe('buildGlossaryLegend', () => {
150
+ it('returns empty string for empty map', () => {
151
+ expect(buildGlossaryLegend(new Map())).toBe('');
152
+ });
153
+ it('includes all refs in the legend', () => {
154
+ const refs = new Map([[TOKEN_A, REF_A], [TOKEN_B, REF_B]]);
155
+ const legend = buildGlossaryLegend(refs);
156
+ expect(legend).toContain('$P1');
157
+ expect(legend).toContain('$P2');
158
+ expect(legend).toContain(TOKEN_A);
159
+ expect(legend).toContain(TOKEN_B);
160
+ expect(legend).toContain('[squeezr-glossary:');
161
+ });
162
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Tests for v1.48.0 image dedup hash-based.
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { dedupImagesAnthropic } from '../imageDedup.js';
6
+ describe('imageDedup', () => {
7
+ it('replaces duplicate base64 images with text placeholders', () => {
8
+ const img = { type: 'image', source: { type: 'base64', data: 'abc'.repeat(500), media_type: 'image/png' } };
9
+ const messages = [
10
+ { role: 'user', content: [{ ...img }, { type: 'text', text: 'first ask' }] },
11
+ { role: 'assistant', content: [{ type: 'text', text: 'reply' }] },
12
+ { role: 'user', content: [{ ...img }, { type: 'text', text: 'follow-up' }] },
13
+ ];
14
+ const r = dedupImagesAnthropic(messages);
15
+ expect(r.dedupCount).toBe(1);
16
+ expect(messages[0].content[0].type).toBe('text'); // first image got replaced
17
+ expect(messages[0].content[0].text).toContain('squeezr_expand');
18
+ expect(messages[2].content[0].type).toBe('image'); // last image stays
19
+ expect(r.savedChars).toBeGreaterThan(0);
20
+ });
21
+ it('keeps single occurrences untouched', () => {
22
+ const messages = [
23
+ { role: 'user', content: [{ type: 'image', source: { type: 'base64', data: 'xyz' } }] },
24
+ ];
25
+ const r = dedupImagesAnthropic(messages);
26
+ expect(r.dedupCount).toBe(0);
27
+ expect(messages[0].content[0].type).toBe('image');
28
+ });
29
+ it('ignores non-image content blocks entirely', () => {
30
+ const messages = [
31
+ { role: 'user', content: [{ type: 'text', text: 'hello' }, { type: 'tool_result', tool_use_id: 't1', content: 'output' }] },
32
+ ];
33
+ const r = dedupImagesAnthropic(messages);
34
+ expect(r.dedupCount).toBe(0);
35
+ expect(messages[0].content[0].text).toBe('hello');
36
+ expect(messages[0].content[1].type).toBe('tool_result');
37
+ });
38
+ it('treats different base64 data as distinct images', () => {
39
+ const messages = [
40
+ { role: 'user', content: [{ type: 'image', source: { type: 'base64', data: 'aaa'.repeat(500) } }] },
41
+ { role: 'user', content: [{ type: 'image', source: { type: 'base64', data: 'bbb'.repeat(500) } }] },
42
+ ];
43
+ const r = dedupImagesAnthropic(messages);
44
+ expect(r.dedupCount).toBe(0);
45
+ });
46
+ it('dedupes URL-source images by URL', () => {
47
+ const messages = [
48
+ { role: 'user', content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/img.png' } }] },
49
+ { role: 'user', content: [{ type: 'image', source: { type: 'url', url: 'https://example.com/img.png' } }] },
50
+ ];
51
+ const r = dedupImagesAnthropic(messages);
52
+ expect(r.dedupCount).toBe(1);
53
+ expect(messages[0].content[0].type).toBe('text');
54
+ expect(messages[1].content[0].type).toBe('image');
55
+ });
56
+ it('handles 3+ occurrences keeping only the last', () => {
57
+ const img = { type: 'image', source: { type: 'base64', data: 'same'.repeat(500) } };
58
+ const messages = [
59
+ { role: 'user', content: [{ ...img }] },
60
+ { role: 'assistant', content: [{ type: 'text', text: 'a' }] },
61
+ { role: 'user', content: [{ ...img }] },
62
+ { role: 'assistant', content: [{ type: 'text', text: 'b' }] },
63
+ { role: 'user', content: [{ ...img }] },
64
+ ];
65
+ const r = dedupImagesAnthropic(messages);
66
+ expect(r.dedupCount).toBe(2);
67
+ expect(messages[0].content[0].type).toBe('text');
68
+ expect(messages[2].content[0].type).toBe('text');
69
+ expect(messages[4].content[0].type).toBe('image');
70
+ });
71
+ it('does not touch string-content messages', () => {
72
+ const messages = [
73
+ { role: 'user', content: 'plain text message' },
74
+ { role: 'user', content: 'another plain text' },
75
+ ];
76
+ const r = dedupImagesAnthropic(messages);
77
+ expect(r.dedupCount).toBe(0);
78
+ expect(messages[0].content).toBe('plain text message');
79
+ });
80
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ // compressor.ts imports the AI SDKs at module load; stub them so the import is cheap.
3
+ vi.mock('@anthropic-ai/sdk', () => ({ default: vi.fn().mockImplementation(function () { return {}; }) }));
4
+ vi.mock('openai', () => ({ default: vi.fn().mockImplementation(function () { return {}; }) }));
5
+ import { splitOnLines } from '../compressor.js';
6
+ describe('splitOnLines (large-block chunking — no data loss)', () => {
7
+ it('returns the text as a single chunk when it fits', () => {
8
+ const text = 'line a\nline b\nline c';
9
+ expect(splitOnLines(text, 1000)).toEqual([text]);
10
+ });
11
+ it('covers the ENTIRE input including the tail sentinel when chunked', () => {
12
+ // Build a >13k block of numbered lines with a unique tail sentinel.
13
+ const lines = [];
14
+ for (let i = 0; i < 2000; i++)
15
+ lines.push(`line ${i}: some tool output content here`);
16
+ lines.push('TAIL_SENTINEL_UNIQUE_98765 at src/edge/case.ts');
17
+ const text = lines.join('\n');
18
+ expect(text.length).toBeGreaterThan(13000);
19
+ const chunks = splitOnLines(text, 13000);
20
+ expect(chunks.length).toBeGreaterThan(1);
21
+ // Every chunk is within budget.
22
+ for (const c of chunks)
23
+ expect(c.length).toBeLessThanOrEqual(13000);
24
+ // The tail sentinel must survive somewhere — the old slice(0,4000) dropped it.
25
+ expect(chunks.some(c => c.includes('TAIL_SENTINEL_UNIQUE_98765'))).toBe(true);
26
+ // Rejoining reconstructs the original exactly (no bytes lost at boundaries).
27
+ expect(chunks.join('\n')).toBe(text);
28
+ });
29
+ it('hard-splits a single line longer than maxChars without losing content', () => {
30
+ const huge = 'x'.repeat(5000);
31
+ const chunks = splitOnLines(huge, 1000);
32
+ expect(chunks.length).toBe(5);
33
+ expect(chunks.join('')).toBe(huge);
34
+ });
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { filterMcpTools, mcpServerOf } from '../mcpFilter.js';
3
+ function tool(name, descLen = 100) {
4
+ return { name, description: 'd'.repeat(descLen), input_schema: { type: 'object' } };
5
+ }
6
+ const TOOLS = [
7
+ tool('Bash'),
8
+ tool('Read'),
9
+ tool('mcp__planning-task-mcp__create_task'),
10
+ tool('mcp__planning-task-mcp__list_tasks'),
11
+ tool('mcp__github-mcp__create_pr'),
12
+ tool('mcp__memory-mcp__get_stats'),
13
+ ];
14
+ const NO_MSGS = [];
15
+ describe('mcpServerOf', () => {
16
+ it('extracts server from mcp tool name', () => {
17
+ expect(mcpServerOf('mcp__github-mcp__create_pr')).toBe('github-mcp');
18
+ });
19
+ it('returns null for built-ins', () => {
20
+ expect(mcpServerOf('Bash')).toBe(null);
21
+ });
22
+ it('handles server-only names', () => {
23
+ expect(mcpServerOf('mcp__solo')).toBe('solo');
24
+ });
25
+ });
26
+ describe('filterMcpTools', () => {
27
+ it('no-op when both lists empty', () => {
28
+ const { tools, result } = filterMcpTools(TOOLS, NO_MSGS, new Set(), new Set());
29
+ expect(tools.length).toBe(6);
30
+ expect(result.removedTools).toBe(0);
31
+ });
32
+ it('blocks listed servers', () => {
33
+ const { tools, result } = filterMcpTools(TOOLS, NO_MSGS, new Set(['planning-task-mcp']), new Set());
34
+ expect(tools.length).toBe(4);
35
+ expect(result.removedTools).toBe(2);
36
+ expect(result.removedServers).toEqual(['planning-task-mcp']);
37
+ expect(result.savedChars).toBeGreaterThan(0);
38
+ });
39
+ it('allow list keeps only listed servers (built-ins always kept)', () => {
40
+ const { tools, result } = filterMcpTools(TOOLS, NO_MSGS, new Set(), new Set(['github-mcp']));
41
+ const names = tools.map(t => t.name);
42
+ expect(names).toContain('Bash');
43
+ expect(names).toContain('Read');
44
+ expect(names).toContain('mcp__github-mcp__create_pr');
45
+ expect(names).not.toContain('mcp__planning-task-mcp__create_task');
46
+ expect(names).not.toContain('mcp__memory-mcp__get_stats');
47
+ expect(result.removedTools).toBe(3);
48
+ });
49
+ it('allow list takes precedence over block list', () => {
50
+ const { tools } = filterMcpTools(TOOLS, NO_MSGS, new Set(['github-mcp']), new Set(['github-mcp']));
51
+ const names = tools.map(t => t.name);
52
+ // allow wins: github-mcp survives even though blocked
53
+ expect(names).toContain('mcp__github-mcp__create_pr');
54
+ });
55
+ it('NEVER filters built-in tools', () => {
56
+ const { tools } = filterMcpTools(TOOLS, NO_MSGS, new Set(['planning-task-mcp', 'github-mcp', 'memory-mcp']), new Set());
57
+ const names = tools.map(t => t.name);
58
+ expect(names).toContain('Bash');
59
+ expect(names).toContain('Read');
60
+ });
61
+ it('NEVER filters a server used in the conversation', () => {
62
+ const msgs = [
63
+ {
64
+ role: 'assistant',
65
+ content: [{ type: 'tool_use', id: 't1', name: 'mcp__planning-task-mcp__create_task', input: {} }],
66
+ },
67
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] },
68
+ ];
69
+ const { tools, result } = filterMcpTools(TOOLS, msgs, new Set(['planning-task-mcp']), new Set());
70
+ const names = tools.map(t => t.name);
71
+ // Used server is protected
72
+ expect(names).toContain('mcp__planning-task-mcp__create_task');
73
+ expect(names).toContain('mcp__planning-task-mcp__list_tasks');
74
+ expect(result.keptUsedServers).toEqual(['planning-task-mcp']);
75
+ expect(result.removedTools).toBe(0);
76
+ });
77
+ it('handles empty tools array', () => {
78
+ const { tools, result } = filterMcpTools([], NO_MSGS, new Set(['x']), new Set());
79
+ expect(tools.length).toBe(0);
80
+ expect(result.removedTools).toBe(0);
81
+ });
82
+ it('handles malformed tool entries gracefully', () => {
83
+ const badTools = [null, undefined, 'str', 42, tool('mcp__bad-server__x')];
84
+ const { tools } = filterMcpTools(badTools, NO_MSGS, new Set(['bad-server']), new Set());
85
+ expect(tools.length).toBe(4); // bad entries kept, mcp tool removed
86
+ });
87
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Smoke tests for the v1.47.x → v1.49.x feature batch.
3
+ * Verifies each new module fires correctly and is idempotent / byte-stable.
4
+ */
5
+ import { describe, it, expect } from 'vitest';
6
+ import { dedupSkillBlocks } from '../skillDedup.js';
7
+ import { dedupImagesAnthropic } from '../imageDedup.js';
8
+ import { dedupAttachments } from '../attachmentDedup.js';
9
+ import { compressRepeatedReads } from '../diffRead.js';
10
+ import { summarizeStaleTurns } from '../staleTurnSummary.js';
11
+ import { semanticReadCompress } from '../semanticRead.js';
12
+ import { compressAttachmentText } from '../attachmentCompress.js';
13
+ import { compressStockToolDescs } from '../stockToolDescs.js';
14
+ describe('skillDedup', () => {
15
+ it('collapses duplicate skill blocks in system prompt', () => {
16
+ const block = '## Skill: foo\n' + 'long description line with lots of content\n'.repeat(8) + 'final line of the skill block';
17
+ const prompt = `intro\n\n${block}\n\nsome middle content\n\n${block}\n\nmore content`;
18
+ const r = dedupSkillBlocks(prompt);
19
+ expect(r.dedupCount).toBeGreaterThan(0);
20
+ expect(r.savedChars).toBeGreaterThan(0);
21
+ expect(r.text.length).toBeLessThan(prompt.length);
22
+ });
23
+ it('is a no-op on small prompts', () => {
24
+ const r = dedupSkillBlocks('hi');
25
+ expect(r.dedupCount).toBe(0);
26
+ });
27
+ });
28
+ describe('imageDedup', () => {
29
+ it('replaces duplicate images with placeholders', () => {
30
+ const img = { type: 'image', source: { type: 'base64', data: 'abc'.repeat(500), media_type: 'image/png' } };
31
+ const messages = [
32
+ { role: 'user', content: [{ ...img }, { type: 'text', text: 'first ask' }] },
33
+ { role: 'assistant', content: [{ type: 'text', text: 'reply' }] },
34
+ { role: 'user', content: [{ ...img }, { type: 'text', text: 'follow-up' }] },
35
+ ];
36
+ const r = dedupImagesAnthropic(messages);
37
+ expect(r.dedupCount).toBe(1);
38
+ expect(messages[0].content[0].type).toBe('text'); // first image got replaced
39
+ expect(messages[2].content[0].type).toBe('image'); // last image stays
40
+ });
41
+ });
42
+ describe('attachmentDedup', () => {
43
+ it('dedupes large repeated text blocks', () => {
44
+ const big = 'X'.repeat(800);
45
+ const messages = [
46
+ { role: 'user', content: [{ type: 'text', text: big }] },
47
+ { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
48
+ { role: 'user', content: [{ type: 'text', text: big }] },
49
+ { role: 'assistant', content: [{ type: 'text', text: 'live' }] },
50
+ { role: 'user', content: [{ type: 'text', text: 'live ask' }] },
51
+ ];
52
+ const r = dedupAttachments(messages);
53
+ expect(r.dedupCount).toBeGreaterThan(0);
54
+ });
55
+ });
56
+ // promptCache removed in v1.51.0 — Claude Code does its own caching natively
57
+ // mcpToolFilter removed in v1.52.0 — caused 429 rate-limit cascades on real Claude Code traffic
58
+ describe('semanticRead', () => {
59
+ it('elides function bodies in large code files', () => {
60
+ const py = Array.from({ length: 300 }, (_, i) => i % 30 === 0
61
+ ? `def function_${i}():\n x = 1\n y = 2\n z = 3\n a = x + y\n b = y + z\n c = a + b\n return c`
62
+ : `# filler line ${i}`).join('\n');
63
+ const r = semanticReadCompress(py, 'foo.py');
64
+ expect(r.savedChars).toBeGreaterThan(0);
65
+ expect(r.compressed).toContain('def function_');
66
+ expect(r.compressed).toContain('squeezr');
67
+ });
68
+ it('returns unchanged for small files', () => {
69
+ const r = semanticReadCompress('def f(): pass', 'x.py');
70
+ expect(r.savedChars).toBe(0);
71
+ });
72
+ });
73
+ describe('attachmentCompress', () => {
74
+ it('compresses large CSV', () => {
75
+ const rows = Array.from({ length: 200 }, (_, i) => `${i},${i * 2},${i * 3},name_${i}`);
76
+ const csv = 'id,a,b,name\n' + rows.join('\n');
77
+ const r = compressAttachmentText(csv);
78
+ expect(r.type).toBe('csv');
79
+ expect(r.savedChars).toBeGreaterThan(0);
80
+ });
81
+ });
82
+ describe('stockToolDescs', () => {
83
+ it('replaces verbose stock tool description with one-liner', () => {
84
+ const body = { tools: [{ name: 'Bash', description: 'X'.repeat(2000) }] };
85
+ const r = compressStockToolDescs(body);
86
+ expect(r.compressed).toBe(1);
87
+ expect(body.tools[0].description.length).toBeLessThan(500);
88
+ });
89
+ });
90
+ describe('staleTurnSummary', () => {
91
+ it('summarizes old turns for sessions >40 turns', () => {
92
+ const messages = Array.from({ length: 50 }, (_, i) => ({
93
+ role: i % 2 === 0 ? 'user' : 'assistant',
94
+ content: `turn ${i} content with some refactor and fix words. ${'lorem '.repeat(100)}`,
95
+ }));
96
+ const r = summarizeStaleTurns(messages);
97
+ expect(r.collapsedTurns).toBeGreaterThan(0);
98
+ expect(r.savedChars).toBeGreaterThan(0);
99
+ });
100
+ it('preserves last 20 turns', () => {
101
+ const messages = Array.from({ length: 50 }, (_, i) => ({
102
+ role: i % 2 === 0 ? 'user' : 'assistant',
103
+ content: `turn ${i} ${'lorem '.repeat(100)}`,
104
+ }));
105
+ summarizeStaleTurns(messages);
106
+ // Last 20 should still have their original content (>500 chars each)
107
+ for (let i = 30; i < 50; i++) {
108
+ expect(messages[i].content).toContain(`turn ${i}`);
109
+ }
110
+ });
111
+ });
112
+ describe('diffRead', () => {
113
+ it('collapses duplicate Reads to references', () => {
114
+ const fileContent = 'line1\nline2\nline3\n'.repeat(200);
115
+ const messages = [
116
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/foo.py' } }] },
117
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: fileContent }] },
118
+ { role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Read', input: { file_path: '/foo.py' } }] },
119
+ { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't2', content: fileContent + 'extra line\n' }] },
120
+ ];
121
+ const r = compressRepeatedReads(messages);
122
+ expect(r.collapsedCount).toBeGreaterThan(0);
123
+ });
124
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Stage 5 — Quality harness.
3
+ *
4
+ * Compresses a corpus of REAL-shaped tool outputs with the live local model (Zest
5
+ * via Ollama) and measures, per fixture:
6
+ * (a) compression ratio (did it actually shrink?)
7
+ * (b) hard-token retention (paths / URLs / error codes MUST survive — 100%)
8
+ * (c) whether the acceptance guardrail would accept the result
9
+ *
10
+ * Skipped automatically when Ollama isn't reachable, so CI without a model stays
11
+ * green. Run locally with Ollama up: npm run test:quality
12
+ */
13
+ import { describe, it, expect } from 'vitest';
14
+ import { compressLargeText } from '../compressor.js';
15
+ import { validateCompression } from '../compressionGuard.js';
16
+ const OLLAMA = process.env.SQUEEZR_LOCAL_UPSTREAM || 'http://localhost:11434';
17
+ const MODEL = process.env.SQUEEZR_LOCAL_MODEL || 'zest';
18
+ async function ollamaUp() {
19
+ try {
20
+ const r = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(1500) });
21
+ return r.ok;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ const UP = await ollamaUp();
28
+ // Hard tokens that MUST survive an ACCEPTED compression: URLs, error/status codes,
29
+ // and explicit filenames. Clean regexes (no greedy path matching) so the harness
30
+ // oracle doesn't produce partial-match artifacts.
31
+ function hardTokens(text) {
32
+ const sets = [
33
+ /https?:\/\/[^\s"'<>)\]]+/g, // URLs
34
+ /\b[\w\-]+\.(?:ts|tsx|js|py|json|md|go|rs|yml|yaml|toml|sql)\b/g, // filenames
35
+ /\b(?:E[A-Z]{2,}|HTTP\s?\d{3}|exit code \d+|errno\s?\d+|status\s?[45]\d{2})\b/g, // error/status codes
36
+ ];
37
+ const out = new Set();
38
+ for (const re of sets)
39
+ for (const m of text.match(re) ?? [])
40
+ out.add(m);
41
+ return [...out];
42
+ }
43
+ const CORPUS = [
44
+ {
45
+ name: 'verbose-file-read',
46
+ text: `import { readFileSync } from 'node:fs'\nimport { join } from 'node:path'\n` +
47
+ Array.from({ length: 60 }, (_, i) => `export function handler${i}(req: Request, res: Response) {\n` +
48
+ ` // process incoming request number ${i} with full validation and logging\n` +
49
+ ` const data = readFileSync(join('/srv/app/data', 'file${i}.json'), 'utf-8')\n` +
50
+ ` if (!data) throw new Error('missing file${i}.json at /srv/app/data')\n return res.json(JSON.parse(data))\n}`).join('\n'),
51
+ },
52
+ {
53
+ name: 'test-failure',
54
+ text: `FAIL src/auth/login.test.ts > rejects bad password\n` +
55
+ `AssertionError: expected 401 to equal 200\n at Object.<anonymous> (src/auth/login.test.ts:88:14)\n` +
56
+ ` at src/auth/session.ts:42:9\nECONNREFUSED connecting to https://api.internal/auth\n` +
57
+ 'stack frame filler line that pads the block to be worth compressing '.repeat(30),
58
+ },
59
+ {
60
+ name: 'build-log',
61
+ text: `> tsc\n` + 'compiling module with verbose diagnostic output and progress notes '.repeat(40) +
62
+ `\nWARNING deprecated API used in src/legacy/parser.ts line 210\n` +
63
+ `see https://example.com/docs/migration for the upgrade path\nDone in 4.2s`,
64
+ },
65
+ {
66
+ name: 'json-response',
67
+ text: JSON.stringify({
68
+ status: 500, error: 'ECONNRESET',
69
+ endpoint: 'https://api.service.com/v2/orders',
70
+ items: Array.from({ length: 40 }, (_, i) => ({ id: i, sku: `SKU-${i}`, qty: i * 2, note: 'a fairly long descriptive note about this line item for padding' })),
71
+ }, null, 2),
72
+ },
73
+ ];
74
+ describe('quality harness (Zest live)', () => {
75
+ it(`Ollama reachable at ${OLLAMA}`, () => {
76
+ if (!UP)
77
+ console.warn(`[harness] Ollama not reachable — quality cases skipped`);
78
+ expect(true).toBe(true);
79
+ });
80
+ for (const fx of CORPUS) {
81
+ it.skipIf(!UP)(`${fx.name}: compresses and keeps all hard tokens`, async () => {
82
+ const out = await compressLargeText(fx.text, OLLAMA, MODEL);
83
+ const ratio = 1 - out.length / fx.text.length;
84
+ const hard = hardTokens(fx.text);
85
+ const lost = hard.filter(t => !out.includes(t));
86
+ const guard = validateCompression(fx.text, out);
87
+ console.log(`[harness] ${fx.name}: ratio=${(ratio * 100).toFixed(0)}% hardTokens=${hard.length} lost=${lost.length} guard=${guard.accept ? 'accept' : 'reject:' + guard.reason}`);
88
+ // It must actually shrink the block.
89
+ expect(ratio).toBeGreaterThan(0);
90
+ // THE safety property: a compression the guardrail ACCEPTS must not have lost
91
+ // any hard token. If Zest mangled one, the guardrail must have rejected it
92
+ // (production then keeps the deterministic form — no quality loss).
93
+ if (guard.accept) {
94
+ expect(lost).toEqual([]);
95
+ }
96
+ }, 30000);
97
+ }
98
+ });
@@ -7,6 +7,12 @@
7
7
  */
8
8
  import { describe, it, expect, vi, afterEach } from 'vitest';
9
9
  import { createServer } from 'node:http';
10
+ // The proxy routes api.anthropic.com through anthropicDirectFetch (direct DNS,
11
+ // bypasses global fetch). Mock it to use global fetch so the stub below applies.
12
+ vi.mock('../anthropicDirectFetch.js', () => ({
13
+ isAnthropicUrl: (url) => url.includes('api.anthropic.com'),
14
+ anthropicDirectFetch: (url, init) => fetch(url, init),
15
+ }));
10
16
  // ── Helper: start a minimal mock Anthropic server ─────────────────────────────
11
17
  const RATE_LIMIT_HEADERS = {
12
18
  'anthropic-ratelimit-requests-limit': '50',
@@ -0,0 +1 @@
1
+ export {};