squeezr-ai 1.11.4 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -2
- package/bin/squeezr.js +30 -9
- package/dist/__tests__/cache.test.js +15 -12
- package/dist/__tests__/codexMitm.test.d.ts +1 -0
- package/dist/__tests__/codexMitm.test.js +288 -0
- package/dist/cache.d.ts +2 -1
- package/dist/cache.js +7 -5
- package/dist/codexMitm.d.ts +4 -0
- package/dist/codexMitm.js +428 -0
- package/dist/index.js +6 -29
- package/dist/server.js +26 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +60 -58
package/README.md
CHANGED
|
@@ -177,7 +177,7 @@ Squeezr auto-detects which provider each request targets from the auth headers.
|
|
|
177
177
|
| CLI | Set this env var | Compresses with | Extra keys needed |
|
|
178
178
|
|---|---|---|---|
|
|
179
179
|
| **Claude Code** | `ANTHROPIC_BASE_URL=http://localhost:8080` | Claude Haiku | None |
|
|
180
|
-
| **Codex CLI** | `
|
|
180
|
+
| **Codex CLI** | `squeezr setup` (see below) | gpt-5.4-mini (via your Codex sub) | None |
|
|
181
181
|
| **Aider** (OpenAI backend) | `openai_base_url=http://localhost:8080` | GPT-4o-mini | None |
|
|
182
182
|
| **Aider** (Anthropic backend) | `ANTHROPIC_BASE_URL=http://localhost:8080` | Claude Haiku | None |
|
|
183
183
|
| **OpenCode** | `openai_base_url=http://localhost:8080` | GPT-4o-mini | None |
|
|
@@ -202,7 +202,11 @@ Then point your CLI at the proxy:
|
|
|
202
202
|
export ANTHROPIC_BASE_URL=http://localhost:8080 # macOS / Linux
|
|
203
203
|
$env:ANTHROPIC_BASE_URL="http://localhost:8080" # Windows PowerShell
|
|
204
204
|
|
|
205
|
-
# Codex
|
|
205
|
+
# Codex (uses MITM proxy — see "Codex deep compression" below)
|
|
206
|
+
export HTTPS_PROXY=http://localhost:8081
|
|
207
|
+
export SSL_CERT_FILE=~/.squeezr/mitm-ca/bundle.crt
|
|
208
|
+
|
|
209
|
+
# Aider / OpenCode
|
|
206
210
|
export openai_base_url=http://localhost:8080
|
|
207
211
|
|
|
208
212
|
# Gemini CLI
|
|
@@ -416,6 +420,44 @@ Shows which deterministic patterns fired, how many outputs hit the AI fallback,
|
|
|
416
420
|
|
|
417
421
|
---
|
|
418
422
|
|
|
423
|
+
## Codex deep compression
|
|
424
|
+
|
|
425
|
+
Codex CLI talks to `chatgpt.com` over WebSocket, not the standard OpenAI API. This means a regular HTTP proxy can't inspect or modify the traffic. Squeezr solves this with a TLS-terminating MITM proxy on port 8081.
|
|
426
|
+
|
|
427
|
+
### How it works
|
|
428
|
+
|
|
429
|
+
1. `squeezr setup` generates a local CA and configures `HTTPS_PROXY` + `SSL_CERT_FILE` in your shell
|
|
430
|
+
2. When Codex connects to `chatgpt.com`, Squeezr intercepts the TLS tunnel and generates a per-host certificate signed by the local CA
|
|
431
|
+
3. Squeezr strips `permessage-deflate` from the WebSocket handshake so frames arrive as plain JSON
|
|
432
|
+
4. On every client-to-server WebSocket frame, Squeezr looks for `function_call_output` messages (tool results) exceeding the compression threshold
|
|
433
|
+
5. For each large tool result, Squeezr opens a **separate** WebSocket to `chatgpt.com/backend-api/codex/responses` using the same OAuth token, and asks `gpt-5.4-mini` to summarize it
|
|
434
|
+
6. The compressed output replaces the original in the frame before forwarding to the server
|
|
435
|
+
|
|
436
|
+
### Setup
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
squeezr setup # auto-configures everything (HTTPS_PROXY, SSL_CERT_FILE, CA)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Or manually:
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
export HTTPS_PROXY=http://localhost:8081
|
|
446
|
+
export SSL_CERT_FILE=~/.squeezr/mitm-ca/bundle.crt
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### What it costs
|
|
450
|
+
|
|
451
|
+
Nothing extra. The compression calls use `gpt-5.4-mini` through the same ChatGPT WebSocket endpoint that your Codex subscription already covers. No API key required.
|
|
452
|
+
|
|
453
|
+
### Results
|
|
454
|
+
|
|
455
|
+
In testing, Codex tool results (file reads, command output) are compressed by **80-90%** per turn. A typical file read of 5,000 chars compresses to ~700 chars, saving thousands of tokens across a session.
|
|
456
|
+
|
|
457
|
+
For a detailed technical explanation, see [CODEX.md](CODEX.md).
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
419
461
|
## How session-level optimisations work
|
|
420
462
|
|
|
421
463
|
### Session cache + differential compression
|
package/bin/squeezr.js
CHANGED
|
@@ -113,12 +113,23 @@ function stopProxy() {
|
|
|
113
113
|
const match = out.match(/LISTENING\s+(\d+)/)
|
|
114
114
|
pid = match?.[1]
|
|
115
115
|
} else {
|
|
116
|
-
|
|
116
|
+
// Use -sTCP:LISTEN to get only the listening process, not connected clients.
|
|
117
|
+
// lsof may return multiple PIDs without this flag.
|
|
118
|
+
try {
|
|
119
|
+
pid = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
|
|
120
|
+
} catch {
|
|
121
|
+
// fallback: fuser (available on most Linux/WSL)
|
|
122
|
+
try {
|
|
123
|
+
pid = execSync(`fuser ${port}/tcp 2>/dev/null`, { encoding: 'utf-8', stdio: 'pipe' }).trim()
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
117
126
|
}
|
|
118
127
|
if (!pid) {
|
|
119
128
|
console.log(`Squeezr is not running on port ${port}`)
|
|
120
129
|
return
|
|
121
130
|
}
|
|
131
|
+
// Take only the first PID in case multiple are returned
|
|
132
|
+
pid = pid.split(/\s+/)[0]
|
|
122
133
|
if (process.platform === 'win32') {
|
|
123
134
|
execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' })
|
|
124
135
|
} else {
|
|
@@ -254,11 +265,16 @@ function setupUnix() {
|
|
|
254
265
|
// 1. Set env vars + auto-heal guard in shell profile
|
|
255
266
|
const distIndex = path.join(ROOT, 'dist', 'index.js')
|
|
256
267
|
const port = process.env.SQUEEZR_PORT || 8080
|
|
268
|
+
const mitmPort = Number(port) + 1
|
|
269
|
+
const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
|
|
257
270
|
const shellBlock = [
|
|
258
271
|
`# squeezr env vars`,
|
|
259
272
|
`export ANTHROPIC_BASE_URL=http://localhost:${port}`,
|
|
260
273
|
`export openai_base_url=http://localhost:${port}`,
|
|
261
274
|
`export GEMINI_API_BASE_URL=http://localhost:${port}`,
|
|
275
|
+
`# squeezr MITM proxy for Codex (TLS interception)`,
|
|
276
|
+
`export HTTPS_PROXY=http://localhost:${mitmPort}`,
|
|
277
|
+
`export SSL_CERT_FILE=${bundlePath}`,
|
|
262
278
|
`# squeezr auto-heal: start proxy if not running`,
|
|
263
279
|
`if ! curl -sf http://localhost:${port}/squeezr/health >/dev/null 2>&1; then`,
|
|
264
280
|
` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
|
|
@@ -277,13 +293,14 @@ function setupUnix() {
|
|
|
277
293
|
fs.appendFileSync(profile, `\n${shellBlock}\n`)
|
|
278
294
|
console.log(` [ok] Env vars + auto-heal added to ${profile}`)
|
|
279
295
|
} else {
|
|
280
|
-
if (!existing.includes('squeezr
|
|
296
|
+
if (!existing.includes('SSL_CERT_FILE') || !existing.includes('squeezr MITM')) {
|
|
297
|
+
// Re-write block to include MITM vars
|
|
281
298
|
const updatedContent = existing.replace(
|
|
282
|
-
/# squeezr env vars\
|
|
299
|
+
/# squeezr env vars[\s\S]*?fi\n/,
|
|
283
300
|
shellBlock + '\n'
|
|
284
301
|
)
|
|
285
302
|
fs.writeFileSync(profile, updatedContent)
|
|
286
|
-
console.log(` [ok]
|
|
303
|
+
console.log(` [ok] Shell profile updated with MITM proxy vars`)
|
|
287
304
|
} else {
|
|
288
305
|
console.log(` [skip] Env vars + auto-heal already in ${profile}`)
|
|
289
306
|
}
|
|
@@ -381,11 +398,15 @@ function setupWSL() {
|
|
|
381
398
|
// it in the background. This is the safety net for WSL2 where systemd and
|
|
382
399
|
// Task Scheduler may both fail.
|
|
383
400
|
const port = process.env.SQUEEZR_PORT || 8080
|
|
401
|
+
const mitmPort = Number(port) + 1
|
|
402
|
+
const bundlePath = path.join(os.homedir(), '.squeezr', 'mitm-ca', 'bundle.crt')
|
|
384
403
|
const shellBlock = [
|
|
385
404
|
`# squeezr env vars`,
|
|
386
405
|
`export ANTHROPIC_BASE_URL=http://localhost:${port}`,
|
|
387
406
|
`export openai_base_url=http://localhost:${port}`,
|
|
388
407
|
`export GEMINI_API_BASE_URL=http://localhost:${port}`,
|
|
408
|
+
`export HTTPS_PROXY=http://localhost:${mitmPort}`,
|
|
409
|
+
`export SSL_CERT_FILE=${bundlePath}`,
|
|
389
410
|
`# squeezr auto-heal: start proxy if not running`,
|
|
390
411
|
`if ! curl -sf http://localhost:${port}/squeezr/health >/dev/null 2>&1; then`,
|
|
391
412
|
` nohup ${nodeExe} ${distIndex} >> "${os.homedir()}/.squeezr/squeezr.log" 2>&1 &`,
|
|
@@ -404,16 +425,16 @@ function setupWSL() {
|
|
|
404
425
|
fs.appendFileSync(profile, `\n${shellBlock}\n`)
|
|
405
426
|
console.log(` [ok] Env vars + auto-heal added to ${profile}`)
|
|
406
427
|
} else {
|
|
407
|
-
// Update existing block
|
|
408
|
-
if (!existing.includes('
|
|
428
|
+
// Update existing block if missing MITM proxy vars
|
|
429
|
+
if (!existing.includes('SSL_CERT_FILE') || !existing.includes('HTTPS_PROXY')) {
|
|
409
430
|
const updatedContent = existing.replace(
|
|
410
|
-
/# squeezr env vars\
|
|
431
|
+
/# squeezr env vars[\s\S]*?fi\n/,
|
|
411
432
|
shellBlock + '\n'
|
|
412
433
|
)
|
|
413
434
|
fs.writeFileSync(profile, updatedContent)
|
|
414
|
-
console.log(` [ok]
|
|
435
|
+
console.log(` [ok] Shell profile updated with MITM proxy vars`)
|
|
415
436
|
} else {
|
|
416
|
-
console.log(` [skip] Env vars
|
|
437
|
+
console.log(` [skip] Env vars already in ${profile}`)
|
|
417
438
|
}
|
|
418
439
|
}
|
|
419
440
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
2
|
import { CompressionCache } from '../cache.js';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// Use a unique temp path per test run so no disk state bleeds between tests
|
|
6
|
+
const tmpPath = () => join(tmpdir(), `squeezr-cache-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
3
7
|
describe('CompressionCache', () => {
|
|
4
8
|
let cache;
|
|
5
9
|
beforeEach(() => {
|
|
6
|
-
// maxEntries=5
|
|
7
|
-
cache = new CompressionCache(5);
|
|
10
|
+
// maxEntries=5, isolated temp file — no disk bleed between runs
|
|
11
|
+
cache = new CompressionCache(5, tmpPath());
|
|
8
12
|
});
|
|
9
13
|
it('returns undefined for a cache miss', () => {
|
|
10
14
|
expect(cache.get('never stored this')).toBeUndefined();
|
|
@@ -42,7 +46,6 @@ describe('CompressionCache', () => {
|
|
|
42
46
|
cache.set('c', '3');
|
|
43
47
|
cache.set('d', '4');
|
|
44
48
|
cache.set('e', '5');
|
|
45
|
-
// All 5 entries stored
|
|
46
49
|
expect(cache.stats().size).toBe(5);
|
|
47
50
|
// Add one more — oldest ('a') should be evicted
|
|
48
51
|
cache.set('f', '6');
|
|
@@ -50,19 +53,19 @@ describe('CompressionCache', () => {
|
|
|
50
53
|
expect(cache.get('a')).toBeUndefined();
|
|
51
54
|
expect(cache.get('f')).toBe('6');
|
|
52
55
|
});
|
|
53
|
-
it('reports correct size
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
expect(
|
|
59
|
-
bigCache.set('unique-key-z-' + Date.now(), 'w');
|
|
60
|
-
expect(bigCache.stats().size).toBe(initialSize + 2);
|
|
56
|
+
it('reports correct size after additions', () => {
|
|
57
|
+
expect(cache.stats().size).toBe(0); // fresh isolated cache
|
|
58
|
+
cache.set('key1', 'val1');
|
|
59
|
+
expect(cache.stats().size).toBe(1);
|
|
60
|
+
cache.set('key2', 'val2');
|
|
61
|
+
expect(cache.stats().size).toBe(2);
|
|
61
62
|
});
|
|
62
|
-
it('overwrites existing entry', () => {
|
|
63
|
+
it('overwrites existing entry without growing size', () => {
|
|
63
64
|
cache.set('key', 'first');
|
|
65
|
+
expect(cache.stats().size).toBe(1);
|
|
64
66
|
cache.set('key', 'second');
|
|
65
67
|
expect(cache.get('key')).toBe('second');
|
|
68
|
+
expect(cache.stats().size).toBe(1);
|
|
66
69
|
});
|
|
67
70
|
it('different texts produce different cache entries', () => {
|
|
68
71
|
cache.set('text1', 'compressed1');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
// ── WS frame helpers (inline reimplementation for testing) ────────────────────
|
|
3
|
+
// These mirror the logic in codexMitm.ts without importing it directly
|
|
4
|
+
// (importing would require node-forge CA files to exist)
|
|
5
|
+
function xorMask(data, key) {
|
|
6
|
+
const out = Buffer.from(data);
|
|
7
|
+
for (let i = 0; i < out.length; i++)
|
|
8
|
+
out[i] ^= key[i % 4];
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
function buildWsFrame(opcode, payload, masked) {
|
|
12
|
+
const key = masked ? Buffer.from([0x37, 0xfa, 0x21, 0x3d]) : Buffer.alloc(0);
|
|
13
|
+
const plen = payload.length;
|
|
14
|
+
let hlen = 2 + (masked ? 4 : 0);
|
|
15
|
+
if (plen >= 65536)
|
|
16
|
+
hlen += 8;
|
|
17
|
+
else if (plen >= 126)
|
|
18
|
+
hlen += 2;
|
|
19
|
+
const frame = Buffer.alloc(hlen + plen);
|
|
20
|
+
frame[0] = 0x80 | opcode;
|
|
21
|
+
if (plen >= 126) {
|
|
22
|
+
frame[1] = (masked ? 0x80 : 0) | 126;
|
|
23
|
+
frame.writeUInt16BE(plen, 2);
|
|
24
|
+
if (masked)
|
|
25
|
+
key.copy(frame, 4);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
frame[1] = (masked ? 0x80 : 0) | plen;
|
|
29
|
+
if (masked)
|
|
30
|
+
key.copy(frame, 2);
|
|
31
|
+
}
|
|
32
|
+
const body = masked ? xorMask(payload, key) : payload;
|
|
33
|
+
body.copy(frame, hlen);
|
|
34
|
+
return frame;
|
|
35
|
+
}
|
|
36
|
+
function parseWsFrame(buf) {
|
|
37
|
+
if (buf.length < 2)
|
|
38
|
+
return null;
|
|
39
|
+
const opcode = buf[0] & 0x0F;
|
|
40
|
+
const masked = !!(buf[1] & 0x80);
|
|
41
|
+
let plen = buf[1] & 0x7F;
|
|
42
|
+
let hlen = 2;
|
|
43
|
+
if (plen === 126) {
|
|
44
|
+
if (buf.length < 4)
|
|
45
|
+
return null;
|
|
46
|
+
plen = buf.readUInt16BE(2);
|
|
47
|
+
hlen = 4;
|
|
48
|
+
}
|
|
49
|
+
else if (plen === 127) {
|
|
50
|
+
if (buf.length < 10)
|
|
51
|
+
return null;
|
|
52
|
+
plen = Number(buf.readBigUInt64BE(2));
|
|
53
|
+
hlen = 10;
|
|
54
|
+
}
|
|
55
|
+
const mask = Buffer.alloc(4);
|
|
56
|
+
if (masked) {
|
|
57
|
+
if (buf.length < hlen + 4)
|
|
58
|
+
return null;
|
|
59
|
+
buf.copy(mask, 0, hlen, hlen + 4);
|
|
60
|
+
hlen += 4;
|
|
61
|
+
}
|
|
62
|
+
if (buf.length < hlen + plen)
|
|
63
|
+
return null;
|
|
64
|
+
return { opcode, masked, mask, payload: buf.slice(hlen, hlen + plen), total: hlen + plen };
|
|
65
|
+
}
|
|
66
|
+
// ── WS frame tests ────────────────────────────────────────────────────────────
|
|
67
|
+
describe('WS frame helpers', () => {
|
|
68
|
+
it('xorMask is its own inverse', () => {
|
|
69
|
+
const data = Buffer.from('hello world');
|
|
70
|
+
const key = Buffer.from([0xAB, 0xCD, 0xEF, 0x12]);
|
|
71
|
+
expect(xorMask(xorMask(data, key), key).toString()).toBe('hello world');
|
|
72
|
+
});
|
|
73
|
+
it('builds and parses an unmasked text frame', () => {
|
|
74
|
+
const payload = Buffer.from('{"type":"ping"}');
|
|
75
|
+
const frame = buildWsFrame(1, payload, false);
|
|
76
|
+
const parsed = parseWsFrame(frame);
|
|
77
|
+
expect(parsed).not.toBeNull();
|
|
78
|
+
expect(parsed.opcode).toBe(1);
|
|
79
|
+
expect(parsed.masked).toBe(false);
|
|
80
|
+
expect(parsed.payload.toString()).toBe('{"type":"ping"}');
|
|
81
|
+
expect(parsed.total).toBe(frame.length);
|
|
82
|
+
});
|
|
83
|
+
it('builds and parses a masked text frame', () => {
|
|
84
|
+
const payload = Buffer.from('{"type":"response.create"}');
|
|
85
|
+
const frame = buildWsFrame(1, payload, true);
|
|
86
|
+
const parsed = parseWsFrame(frame);
|
|
87
|
+
expect(parsed).not.toBeNull();
|
|
88
|
+
expect(parsed.opcode).toBe(1);
|
|
89
|
+
expect(parsed.masked).toBe(true);
|
|
90
|
+
const plain = xorMask(parsed.payload, parsed.mask);
|
|
91
|
+
expect(plain.toString()).toBe('{"type":"response.create"}');
|
|
92
|
+
});
|
|
93
|
+
it('builds a 126-byte extended length frame', () => {
|
|
94
|
+
const payload = Buffer.alloc(130, 0x41); // 130 'A' chars
|
|
95
|
+
const frame = buildWsFrame(2, payload, false);
|
|
96
|
+
const parsed = parseWsFrame(frame);
|
|
97
|
+
expect(parsed.opcode).toBe(2);
|
|
98
|
+
expect(parsed.payload.length).toBe(130);
|
|
99
|
+
});
|
|
100
|
+
it('returns null for incomplete frame', () => {
|
|
101
|
+
const partial = Buffer.from([0x81, 0x05, 0x48]); // says 5-byte payload, only 1 byte
|
|
102
|
+
expect(parseWsFrame(partial)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
it('handles empty payload', () => {
|
|
105
|
+
const frame = buildWsFrame(1, Buffer.alloc(0), false);
|
|
106
|
+
const parsed = parseWsFrame(frame);
|
|
107
|
+
expect(parsed.payload.length).toBe(0);
|
|
108
|
+
expect(parsed.total).toBe(2);
|
|
109
|
+
});
|
|
110
|
+
it('FIN bit is always set', () => {
|
|
111
|
+
const frame = buildWsFrame(1, Buffer.from('x'), false);
|
|
112
|
+
expect(frame[0] & 0x80).toBe(0x80);
|
|
113
|
+
});
|
|
114
|
+
it('roundtrip: masked frame → parse → unmask → same payload', () => {
|
|
115
|
+
const original = Buffer.from(JSON.stringify({ type: 'response.create', model: 'gpt-5.4-mini' }));
|
|
116
|
+
const frame = buildWsFrame(1, original, true);
|
|
117
|
+
const parsed = parseWsFrame(frame);
|
|
118
|
+
const plain = xorMask(parsed.payload, parsed.mask);
|
|
119
|
+
expect(plain.toString()).toBe(original.toString());
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
// ── Compression threshold logic (unit test, no network) ──────────────────────
|
|
123
|
+
describe('processCodexRequest logic', () => {
|
|
124
|
+
// Replicate the field detection logic from codexMitm.ts
|
|
125
|
+
function findToolMessages(input) {
|
|
126
|
+
return input.flatMap(msg => {
|
|
127
|
+
const isToolMsg = msg.type === 'function_call_output' || msg.role === 'tool' || msg.role === 'function';
|
|
128
|
+
if (!isToolMsg)
|
|
129
|
+
return [];
|
|
130
|
+
const text = msg.output ?? (typeof msg.content === 'string' ? msg.content : null);
|
|
131
|
+
if (!text)
|
|
132
|
+
return [];
|
|
133
|
+
return [{ text, field: msg.output !== undefined ? 'output' : 'content' }];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
it('detects function_call_output (Responses API format)', () => {
|
|
137
|
+
const msgs = [
|
|
138
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'file contents here' },
|
|
139
|
+
{ role: 'user', content: 'read the file' },
|
|
140
|
+
];
|
|
141
|
+
const found = findToolMessages(msgs);
|
|
142
|
+
expect(found).toHaveLength(1);
|
|
143
|
+
expect(found[0].text).toBe('file contents here');
|
|
144
|
+
expect(found[0].field).toBe('output');
|
|
145
|
+
});
|
|
146
|
+
it('detects role=tool (Chat Completions format)', () => {
|
|
147
|
+
const msgs = [
|
|
148
|
+
{ role: 'tool', tool_call_id: 't1', content: 'shell output' },
|
|
149
|
+
{ role: 'user', content: 'run ls' },
|
|
150
|
+
];
|
|
151
|
+
const found = findToolMessages(msgs);
|
|
152
|
+
expect(found).toHaveLength(1);
|
|
153
|
+
expect(found[0].text).toBe('shell output');
|
|
154
|
+
expect(found[0].field).toBe('content');
|
|
155
|
+
});
|
|
156
|
+
it('detects role=function', () => {
|
|
157
|
+
const msgs = [{ role: 'function', name: 'bash', content: 'stdout here' }];
|
|
158
|
+
const found = findToolMessages(msgs);
|
|
159
|
+
expect(found).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
it('ignores non-tool messages', () => {
|
|
162
|
+
const msgs = [
|
|
163
|
+
{ role: 'user', content: 'hello' },
|
|
164
|
+
{ role: 'assistant', content: 'world' },
|
|
165
|
+
{ type: 'response.create', model: 'gpt-5.4' },
|
|
166
|
+
];
|
|
167
|
+
expect(findToolMessages(msgs)).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
it('ignores function_call_output with no output field', () => {
|
|
170
|
+
const msgs = [{ type: 'function_call_output', call_id: 'c1' }];
|
|
171
|
+
expect(findToolMessages(msgs)).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
it('handles multiple tool messages in one request', () => {
|
|
174
|
+
const msgs = [
|
|
175
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'first tool' },
|
|
176
|
+
{ type: 'function_call_output', call_id: 'c2', output: 'second tool' },
|
|
177
|
+
{ role: 'user', content: 'question' },
|
|
178
|
+
];
|
|
179
|
+
expect(findToolMessages(msgs)).toHaveLength(2);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
// ── MITM request format ───────────────────────────────────────────────────────
|
|
183
|
+
describe('Codex compression request format', () => {
|
|
184
|
+
const COMPRESS_PROMPT = 'Extract ONLY essential info: errors, file paths, function names, test failures, key values, warnings. Very concise, under 150 tokens. No preamble.';
|
|
185
|
+
function buildCompressMsg(text, model = 'gpt-5.4-mini') {
|
|
186
|
+
return {
|
|
187
|
+
type: 'response.create',
|
|
188
|
+
model,
|
|
189
|
+
instructions: COMPRESS_PROMPT,
|
|
190
|
+
input: [{ role: 'user', content: text.slice(0, 4000) }],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
it('has required top-level fields', () => {
|
|
194
|
+
const msg = buildCompressMsg('some tool output');
|
|
195
|
+
expect(msg.type).toBe('response.create');
|
|
196
|
+
expect(msg.model).toBe('gpt-5.4-mini');
|
|
197
|
+
expect(msg.instructions).toBeTruthy();
|
|
198
|
+
expect(Array.isArray(msg.input)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
it('instructions are at top level, not nested', () => {
|
|
201
|
+
const msg = buildCompressMsg('x');
|
|
202
|
+
expect(msg.instructions).toBeTruthy();
|
|
203
|
+
expect(msg.response).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
it('truncates input to 4000 chars', () => {
|
|
206
|
+
const longText = 'a'.repeat(10_000);
|
|
207
|
+
const msg = buildCompressMsg(longText);
|
|
208
|
+
expect(msg.input[0].content.length).toBe(4000);
|
|
209
|
+
});
|
|
210
|
+
it('uses gpt-5.4-mini model', () => {
|
|
211
|
+
expect(buildCompressMsg('x').model).toBe('gpt-5.4-mini');
|
|
212
|
+
});
|
|
213
|
+
it('serializes to valid JSON', () => {
|
|
214
|
+
const msg = buildCompressMsg('tool output content');
|
|
215
|
+
expect(() => JSON.parse(JSON.stringify(msg))).not.toThrow();
|
|
216
|
+
});
|
|
217
|
+
it('wraps properly in a WS frame', () => {
|
|
218
|
+
const msg = buildCompressMsg('tool output');
|
|
219
|
+
const payload = Buffer.from(JSON.stringify(msg));
|
|
220
|
+
const frame = buildWsFrame(1, payload, true); // masked, client→server
|
|
221
|
+
const parsed = parseWsFrame(frame);
|
|
222
|
+
const plain = xorMask(parsed.payload, parsed.mask);
|
|
223
|
+
const decoded = JSON.parse(plain.toString());
|
|
224
|
+
expect(decoded.type).toBe('response.create');
|
|
225
|
+
expect(decoded.model).toBe('gpt-5.4-mini');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// ── Compression threshold ─────────────────────────────────────────────────────
|
|
229
|
+
describe('compression threshold', () => {
|
|
230
|
+
const THRESHOLD = 800;
|
|
231
|
+
it('skips short tool outputs', () => {
|
|
232
|
+
const text = 'short output';
|
|
233
|
+
expect(text.length < THRESHOLD).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
it('compresses long tool outputs', () => {
|
|
236
|
+
const text = 'a'.repeat(1000);
|
|
237
|
+
expect(text.length >= THRESHOLD).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
it('only saves if compressed is shorter', () => {
|
|
240
|
+
const original = 'a'.repeat(1000);
|
|
241
|
+
const compressed = 'summary';
|
|
242
|
+
const saved = original.length - compressed.length;
|
|
243
|
+
expect(saved).toBeGreaterThan(0);
|
|
244
|
+
});
|
|
245
|
+
it('falls back to original if compression made it longer', () => {
|
|
246
|
+
const original = 'short';
|
|
247
|
+
const compressed = 'this is actually longer than the original text';
|
|
248
|
+
const shouldApply = compressed.length < original.length;
|
|
249
|
+
expect(shouldApply).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
// ── WebSocket upgrade request manipulation ────────────────────────────────────
|
|
253
|
+
describe('upgrade request header stripping', () => {
|
|
254
|
+
it('strips Sec-WebSocket-Extensions header', () => {
|
|
255
|
+
const upgrade = [
|
|
256
|
+
'GET /backend-api/codex/responses HTTP/1.1',
|
|
257
|
+
'Host: chatgpt.com',
|
|
258
|
+
'Authorization: Bearer eyJ...',
|
|
259
|
+
'Upgrade: websocket',
|
|
260
|
+
'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits',
|
|
261
|
+
'Sec-WebSocket-Key: abc123==',
|
|
262
|
+
'',
|
|
263
|
+
].join('\r\n');
|
|
264
|
+
const stripped = upgrade.replace(/Sec-WebSocket-Extensions:[^\r\n]*\r\n/gi, '');
|
|
265
|
+
expect(stripped).not.toContain('Sec-WebSocket-Extensions');
|
|
266
|
+
expect(stripped).toContain('Upgrade: websocket');
|
|
267
|
+
expect(stripped).toContain('Authorization: Bearer');
|
|
268
|
+
});
|
|
269
|
+
it('extracts Authorization header', () => {
|
|
270
|
+
const raw = 'GET /backend-api/codex/responses HTTP/1.1\r\nAuthorization: Bearer eyJmoo\r\n\r\n';
|
|
271
|
+
const match = raw.match(/[Aa]uthorization:\s*(Bearer [^\r\n]+)/);
|
|
272
|
+
expect(match?.[1]).toBe('Bearer eyJmoo');
|
|
273
|
+
});
|
|
274
|
+
it('extracts chatgpt-account-id header', () => {
|
|
275
|
+
const raw = 'GET / HTTP/1.1\r\nchatgpt-account-id: acc-abc123\r\n\r\n';
|
|
276
|
+
const match = raw.match(/chatgpt-account-id:\s*([^\r\n]+)/i);
|
|
277
|
+
expect(match?.[1]).toBe('acc-abc123');
|
|
278
|
+
});
|
|
279
|
+
it('detects Codex WS path', () => {
|
|
280
|
+
const peek = 'get /backend-api/codex/responses http/1.1\r\nupgrade: websocket\r\n';
|
|
281
|
+
expect(peek.includes('upgrade: websocket')).toBe(true);
|
|
282
|
+
expect(peek.includes('/backend-api/codex/responses')).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
it('does not detect non-Codex WS as Codex', () => {
|
|
285
|
+
const peek = 'get /chat/stream http/1.1\r\nupgrade: websocket\r\n';
|
|
286
|
+
expect(peek.includes('/backend-api/codex/responses')).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
});
|
package/dist/cache.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ export declare class CompressionCache {
|
|
|
3
3
|
private store;
|
|
4
4
|
private hits;
|
|
5
5
|
private misses;
|
|
6
|
-
|
|
6
|
+
private readonly cachePath;
|
|
7
|
+
constructor(maxEntries: number, cachePath?: string);
|
|
7
8
|
private key;
|
|
8
9
|
get(text: string): string | undefined;
|
|
9
10
|
set(text: string, compressed: string): void;
|
package/dist/cache.js
CHANGED
|
@@ -2,14 +2,16 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
|
-
const
|
|
5
|
+
const DEFAULT_CACHE_PATH = join(homedir(), '.squeezr', 'cache.json');
|
|
6
6
|
export class CompressionCache {
|
|
7
7
|
maxEntries;
|
|
8
8
|
store = new Map();
|
|
9
9
|
hits = 0;
|
|
10
10
|
misses = 0;
|
|
11
|
-
|
|
11
|
+
cachePath;
|
|
12
|
+
constructor(maxEntries, cachePath = DEFAULT_CACHE_PATH) {
|
|
12
13
|
this.maxEntries = maxEntries;
|
|
14
|
+
this.cachePath = cachePath;
|
|
13
15
|
this.load();
|
|
14
16
|
}
|
|
15
17
|
key(text) {
|
|
@@ -44,8 +46,8 @@ export class CompressionCache {
|
|
|
44
46
|
}
|
|
45
47
|
load() {
|
|
46
48
|
try {
|
|
47
|
-
if (existsSync(
|
|
48
|
-
const raw = JSON.parse(readFileSync(
|
|
49
|
+
if (existsSync(this.cachePath)) {
|
|
50
|
+
const raw = JSON.parse(readFileSync(this.cachePath, 'utf-8'));
|
|
49
51
|
for (const [k, v] of Object.entries(raw)) {
|
|
50
52
|
this.store.set(k, v);
|
|
51
53
|
}
|
|
@@ -58,7 +60,7 @@ export class CompressionCache {
|
|
|
58
60
|
const dir = join(homedir(), '.squeezr');
|
|
59
61
|
if (!existsSync(dir))
|
|
60
62
|
mkdirSync(dir, { recursive: true });
|
|
61
|
-
writeFileSync(
|
|
63
|
+
writeFileSync(this.cachePath, JSON.stringify(Object.fromEntries(this.store)));
|
|
62
64
|
}
|
|
63
65
|
catch { /* ignore */ }
|
|
64
66
|
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import forge from 'node-forge';
|
|
9
|
+
import { config } from './config.js';
|
|
10
|
+
// ── CA / cert paths ───────────────────────────────────────────────────────────
|
|
11
|
+
const CA_DIR = join(homedir(), '.squeezr', 'mitm-ca');
|
|
12
|
+
const CA_KEY_PATH = join(CA_DIR, 'ca.key');
|
|
13
|
+
const CA_CERT_PATH = join(CA_DIR, 'ca.crt');
|
|
14
|
+
export const BUNDLE_PATH = join(CA_DIR, 'bundle.crt');
|
|
15
|
+
export const MITM_PORT = (config.port ?? 8080) + 1;
|
|
16
|
+
// ── CA generation ─────────────────────────────────────────────────────────────
|
|
17
|
+
function ensureCA() {
|
|
18
|
+
if (fs.existsSync(CA_KEY_PATH) && fs.existsSync(CA_CERT_PATH))
|
|
19
|
+
return;
|
|
20
|
+
fs.mkdirSync(CA_DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
22
|
+
const cert = forge.pki.createCertificate();
|
|
23
|
+
cert.publicKey = keys.publicKey;
|
|
24
|
+
cert.serialNumber = '01';
|
|
25
|
+
cert.validity.notBefore = new Date();
|
|
26
|
+
cert.validity.notAfter = new Date();
|
|
27
|
+
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);
|
|
28
|
+
const attrs = [{ name: 'commonName', value: 'Squeezr-MITM-CA' }];
|
|
29
|
+
cert.setSubject(attrs);
|
|
30
|
+
cert.setIssuer(attrs);
|
|
31
|
+
cert.setExtensions([
|
|
32
|
+
{ name: 'basicConstraints', cA: true },
|
|
33
|
+
{ name: 'keyUsage', keyCertSign: true, cRLSign: true },
|
|
34
|
+
]);
|
|
35
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
36
|
+
fs.writeFileSync(CA_KEY_PATH, forge.pki.privateKeyToPem(keys.privateKey), { mode: 0o600 });
|
|
37
|
+
fs.writeFileSync(CA_CERT_PATH, forge.pki.certificateToPem(cert), { mode: 0o644 });
|
|
38
|
+
const systemCAs = [
|
|
39
|
+
'/etc/ssl/certs/ca-certificates.crt',
|
|
40
|
+
'/etc/ssl/cert.pem',
|
|
41
|
+
].find(p => fs.existsSync(p));
|
|
42
|
+
const bundle = forge.pki.certificateToPem(cert) + (systemCAs ? fs.readFileSync(systemCAs, 'utf-8') : '');
|
|
43
|
+
fs.writeFileSync(BUNDLE_PATH, bundle, { mode: 0o644 });
|
|
44
|
+
console.log(`[squeezr/mitm] CA generated → ${CA_CERT_PATH}`);
|
|
45
|
+
}
|
|
46
|
+
// ── Per-host cert (cached) ────────────────────────────────────────────────────
|
|
47
|
+
const certCache = new Map();
|
|
48
|
+
function getCert(hostname) {
|
|
49
|
+
if (certCache.has(hostname))
|
|
50
|
+
return certCache.get(hostname);
|
|
51
|
+
const caKey = forge.pki.privateKeyFromPem(fs.readFileSync(CA_KEY_PATH, 'utf-8'));
|
|
52
|
+
const caCert = forge.pki.certificateFromPem(fs.readFileSync(CA_CERT_PATH, 'utf-8'));
|
|
53
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
54
|
+
const cert = forge.pki.createCertificate();
|
|
55
|
+
cert.publicKey = keys.publicKey;
|
|
56
|
+
cert.serialNumber = crypto.randomBytes(8).toString('hex');
|
|
57
|
+
cert.validity.notBefore = new Date();
|
|
58
|
+
cert.validity.notAfter = new Date();
|
|
59
|
+
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
|
60
|
+
cert.setSubject([{ name: 'commonName', value: hostname }]);
|
|
61
|
+
cert.setIssuer(caCert.subject.attributes);
|
|
62
|
+
cert.setExtensions([{ name: 'subjectAltName', altNames: [{ type: 2, value: hostname }] }]);
|
|
63
|
+
cert.sign(caKey, forge.md.sha256.create());
|
|
64
|
+
const result = {
|
|
65
|
+
key: forge.pki.privateKeyToPem(keys.privateKey),
|
|
66
|
+
cert: forge.pki.certificateToPem(cert),
|
|
67
|
+
};
|
|
68
|
+
certCache.set(hostname, result);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
// ── WebSocket frame helpers ───────────────────────────────────────────────────
|
|
72
|
+
function xorMask(data, key) {
|
|
73
|
+
const out = Buffer.from(data);
|
|
74
|
+
for (let i = 0; i < out.length; i++)
|
|
75
|
+
out[i] ^= key[i % 4];
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function parseWsFrame(buf) {
|
|
79
|
+
if (buf.length < 2)
|
|
80
|
+
return null;
|
|
81
|
+
const opcode = buf[0] & 0x0F;
|
|
82
|
+
const masked = !!(buf[1] & 0x80);
|
|
83
|
+
let plen = buf[1] & 0x7F;
|
|
84
|
+
let hlen = 2;
|
|
85
|
+
if (plen === 126) {
|
|
86
|
+
if (buf.length < 4)
|
|
87
|
+
return null;
|
|
88
|
+
plen = buf.readUInt16BE(2);
|
|
89
|
+
hlen = 4;
|
|
90
|
+
}
|
|
91
|
+
else if (plen === 127) {
|
|
92
|
+
if (buf.length < 10)
|
|
93
|
+
return null;
|
|
94
|
+
plen = Number(buf.readBigUInt64BE(2));
|
|
95
|
+
hlen = 10;
|
|
96
|
+
}
|
|
97
|
+
const mask = Buffer.alloc(4);
|
|
98
|
+
if (masked) {
|
|
99
|
+
if (buf.length < hlen + 4)
|
|
100
|
+
return null;
|
|
101
|
+
buf.copy(mask, 0, hlen, hlen + 4);
|
|
102
|
+
hlen += 4;
|
|
103
|
+
}
|
|
104
|
+
if (buf.length < hlen + plen)
|
|
105
|
+
return null;
|
|
106
|
+
return { opcode, masked, mask, payload: buf.slice(hlen, hlen + plen), total: hlen + plen };
|
|
107
|
+
}
|
|
108
|
+
function buildWsFrame(opcode, payload, masked) {
|
|
109
|
+
const key = masked ? crypto.randomBytes(4) : Buffer.alloc(0);
|
|
110
|
+
const plen = payload.length;
|
|
111
|
+
let hlen = 2 + (masked ? 4 : 0);
|
|
112
|
+
if (plen >= 65536)
|
|
113
|
+
hlen += 8;
|
|
114
|
+
else if (plen >= 126)
|
|
115
|
+
hlen += 2;
|
|
116
|
+
const frame = Buffer.alloc(hlen + plen);
|
|
117
|
+
frame[0] = 0x80 | opcode;
|
|
118
|
+
if (plen >= 65536) {
|
|
119
|
+
frame[1] = (masked ? 0x80 : 0) | 127;
|
|
120
|
+
frame.writeBigUInt64BE(BigInt(plen), 2);
|
|
121
|
+
if (masked)
|
|
122
|
+
key.copy(frame, 10);
|
|
123
|
+
}
|
|
124
|
+
else if (plen >= 126) {
|
|
125
|
+
frame[1] = (masked ? 0x80 : 0) | 126;
|
|
126
|
+
frame.writeUInt16BE(plen, 2);
|
|
127
|
+
if (masked)
|
|
128
|
+
key.copy(frame, 4);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
frame[1] = (masked ? 0x80 : 0) | plen;
|
|
132
|
+
if (masked)
|
|
133
|
+
key.copy(frame, 2);
|
|
134
|
+
}
|
|
135
|
+
const body = masked ? xorMask(payload, key) : payload;
|
|
136
|
+
body.copy(frame, hlen);
|
|
137
|
+
return frame;
|
|
138
|
+
}
|
|
139
|
+
// ── Compress via separate WS to chatgpt.com ──────────────────────────────────
|
|
140
|
+
const COMPRESS_THRESHOLD = config.threshold ?? 800;
|
|
141
|
+
const COMPRESS_MODEL = 'gpt-5.4-mini';
|
|
142
|
+
const COMPRESS_PROMPT = 'Extract ONLY essential info: errors, file paths, function names, test failures, key values, warnings. Very concise, under 150 tokens. No preamble.';
|
|
143
|
+
function compressViaWs(text, authToken, accountId) {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const timeout = setTimeout(() => { resolve(text); socket.destroy(); }, 15_000);
|
|
146
|
+
const wsKey = crypto.randomBytes(16).toString('base64');
|
|
147
|
+
const upgradeReq = [
|
|
148
|
+
'GET /backend-api/codex/responses HTTP/1.1',
|
|
149
|
+
'Host: chatgpt.com',
|
|
150
|
+
`Authorization: ${authToken}`,
|
|
151
|
+
'Upgrade: websocket',
|
|
152
|
+
'Connection: Upgrade',
|
|
153
|
+
`Sec-WebSocket-Key: ${wsKey}`,
|
|
154
|
+
'Sec-WebSocket-Version: 13',
|
|
155
|
+
'Originator: codex_exec',
|
|
156
|
+
...(accountId ? [`chatgpt-account-id: ${accountId}`] : []),
|
|
157
|
+
'', '',
|
|
158
|
+
].join('\r\n');
|
|
159
|
+
const socket = tls.connect(443, 'chatgpt.com', { servername: 'chatgpt.com' }, () => {
|
|
160
|
+
socket.write(upgradeReq);
|
|
161
|
+
});
|
|
162
|
+
socket.on('error', () => { clearTimeout(timeout); resolve(text); });
|
|
163
|
+
let gotUpgrade = false;
|
|
164
|
+
let buf = Buffer.alloc(0);
|
|
165
|
+
socket.on('data', (chunk) => {
|
|
166
|
+
buf = Buffer.concat([buf, chunk]);
|
|
167
|
+
if (!gotUpgrade) {
|
|
168
|
+
const str = buf.toString('latin1');
|
|
169
|
+
if (!str.includes('\r\n\r\n'))
|
|
170
|
+
return;
|
|
171
|
+
const headerEnd = str.indexOf('\r\n\r\n');
|
|
172
|
+
const headers = str.slice(0, headerEnd);
|
|
173
|
+
if (!headers.startsWith('HTTP/1.1 101')) {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
resolve(text);
|
|
176
|
+
socket.destroy();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
gotUpgrade = true;
|
|
180
|
+
buf = buf.slice(headerEnd + 4);
|
|
181
|
+
// Send compression request
|
|
182
|
+
const msg = JSON.stringify({
|
|
183
|
+
type: 'response.create',
|
|
184
|
+
model: COMPRESS_MODEL,
|
|
185
|
+
instructions: COMPRESS_PROMPT,
|
|
186
|
+
input: [{ role: 'user', content: text.slice(0, 4000) }],
|
|
187
|
+
});
|
|
188
|
+
socket.write(buildWsFrame(1, Buffer.from(msg), true));
|
|
189
|
+
}
|
|
190
|
+
// Parse response frames
|
|
191
|
+
while (buf.length >= 2) {
|
|
192
|
+
const f = parseWsFrame(buf);
|
|
193
|
+
if (!f)
|
|
194
|
+
break;
|
|
195
|
+
buf = buf.slice(f.total);
|
|
196
|
+
if (f.opcode === 1) {
|
|
197
|
+
const payload = f.masked ? xorMask(f.payload, f.mask) : f.payload;
|
|
198
|
+
try {
|
|
199
|
+
const evt = JSON.parse(payload.toString('utf-8'));
|
|
200
|
+
if (evt.type === 'response.output_text.done') {
|
|
201
|
+
clearTimeout(timeout);
|
|
202
|
+
resolve(evt.text || text);
|
|
203
|
+
socket.destroy();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (evt.type === 'response.completed' || evt.type === 'response.done') {
|
|
207
|
+
const output = evt.response?.output?.[0]?.content?.[0]?.text ?? '';
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
resolve(output || text);
|
|
210
|
+
socket.destroy();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
}
|
|
216
|
+
else if (f.opcode === 8) {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
resolve(text);
|
|
219
|
+
socket.destroy();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// ── Process Codex request: find tool outputs and compress ─────────────────────
|
|
227
|
+
async function processCodexRequest(json, authToken, accountId) {
|
|
228
|
+
const messages = json.input ?? json.messages ?? [];
|
|
229
|
+
let saved = 0;
|
|
230
|
+
for (const msg of messages) {
|
|
231
|
+
// Responses API: type=function_call_output, output field
|
|
232
|
+
// Chat Completions API: role=tool/function, content field
|
|
233
|
+
const isToolMsg = msg.type === 'function_call_output' || msg.role === 'tool' || msg.role === 'function';
|
|
234
|
+
if (!isToolMsg)
|
|
235
|
+
continue;
|
|
236
|
+
const text = msg.output ?? (typeof msg.content === 'string' ? msg.content : null);
|
|
237
|
+
if (!text || text.length < COMPRESS_THRESHOLD)
|
|
238
|
+
continue;
|
|
239
|
+
const compressed = await compressViaWs(text, authToken, accountId);
|
|
240
|
+
if (compressed.length < text.length) {
|
|
241
|
+
if (msg.output !== undefined)
|
|
242
|
+
msg.output = compressed;
|
|
243
|
+
else
|
|
244
|
+
msg.content = compressed;
|
|
245
|
+
saved += text.length - compressed.length;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return saved;
|
|
249
|
+
}
|
|
250
|
+
// ── CONNECT handler (HTTPS MITM) ─────────────────────────────────────────────
|
|
251
|
+
function handleConnect(req, clientSocket, _head) {
|
|
252
|
+
const [hostname, portStr] = (req.url ?? '').split(':');
|
|
253
|
+
const port = parseInt(portStr) || 443;
|
|
254
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
255
|
+
const { key, cert } = getCert(hostname);
|
|
256
|
+
const clientTls = new tls.TLSSocket(clientSocket, { isServer: true, key, cert });
|
|
257
|
+
clientTls.on('error', () => { });
|
|
258
|
+
// Capture chatgpt-account-id from any HTTP request to chatgpt.com
|
|
259
|
+
let accountId = '';
|
|
260
|
+
clientTls.once('data', (firstChunk) => {
|
|
261
|
+
const raw = firstChunk.toString('latin1');
|
|
262
|
+
const peek = raw.toLowerCase();
|
|
263
|
+
// Capture account-id header
|
|
264
|
+
const acctMatch = raw.match(/chatgpt-account-id:\s*([^\r\n]+)/i);
|
|
265
|
+
if (acctMatch)
|
|
266
|
+
accountId = acctMatch[1].trim();
|
|
267
|
+
// ── WebSocket upgrade ─────────────────────────────────────────────────────
|
|
268
|
+
if (peek.includes('upgrade: websocket')) {
|
|
269
|
+
const isCodexWs = peek.includes('/backend-api/codex/responses');
|
|
270
|
+
// Extract auth token
|
|
271
|
+
const authMatch = raw.match(/[Aa]uthorization:\s*(Bearer [^\r\n]+)/);
|
|
272
|
+
const authToken = authMatch ? authMatch[1].trim() : '';
|
|
273
|
+
// Strip permessage-deflate so frames are plain text (avoids context desync)
|
|
274
|
+
const modified = raw.replace(/Sec-WebSocket-Extensions:[^\r\n]*\r\n/gi, '');
|
|
275
|
+
const upChunk = Buffer.from(modified, 'latin1');
|
|
276
|
+
const upSocket = tls.connect(port, hostname, { servername: hostname }, () => {
|
|
277
|
+
upSocket.write(upChunk);
|
|
278
|
+
});
|
|
279
|
+
upSocket.on('error', () => { try {
|
|
280
|
+
clientTls.destroy();
|
|
281
|
+
}
|
|
282
|
+
catch { } });
|
|
283
|
+
upSocket.once('data', (upgradeResp) => {
|
|
284
|
+
clientTls.write(upgradeResp);
|
|
285
|
+
if (!isCodexWs) {
|
|
286
|
+
// Non-Codex WS: bidirectional passthrough
|
|
287
|
+
upSocket.on('data', (c) => { try {
|
|
288
|
+
clientTls.write(c);
|
|
289
|
+
}
|
|
290
|
+
catch { } });
|
|
291
|
+
clientTls.on('data', (c) => { try {
|
|
292
|
+
upSocket.write(c);
|
|
293
|
+
}
|
|
294
|
+
catch { } });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// ── Codex WS: intercept client→server, compress tool results ──────────
|
|
298
|
+
let clientBuf = Buffer.alloc(0);
|
|
299
|
+
clientTls.on('data', (chunk) => {
|
|
300
|
+
clientBuf = Buffer.concat([clientBuf, chunk]);
|
|
301
|
+
const processNext = async () => {
|
|
302
|
+
while (clientBuf.length >= 2) {
|
|
303
|
+
const frame = parseWsFrame(clientBuf);
|
|
304
|
+
if (!frame)
|
|
305
|
+
break;
|
|
306
|
+
const originalFrame = clientBuf.slice(0, frame.total);
|
|
307
|
+
clientBuf = clientBuf.slice(frame.total);
|
|
308
|
+
if (frame.opcode === 1) {
|
|
309
|
+
const plain = frame.masked ? xorMask(frame.payload, frame.mask) : frame.payload;
|
|
310
|
+
try {
|
|
311
|
+
const json = JSON.parse(plain.toString('utf-8'));
|
|
312
|
+
const saved = await processCodexRequest(json, authToken, accountId);
|
|
313
|
+
if (saved > 0) {
|
|
314
|
+
console.log(`[squeezr/mitm] Codex compressed: -${saved} chars via ${COMPRESS_MODEL}`);
|
|
315
|
+
const newFrame = buildWsFrame(frame.opcode, Buffer.from(JSON.stringify(json)), frame.masked);
|
|
316
|
+
try {
|
|
317
|
+
upSocket.write(newFrame);
|
|
318
|
+
}
|
|
319
|
+
catch { }
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch { }
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
upSocket.write(originalFrame);
|
|
327
|
+
}
|
|
328
|
+
catch { }
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
processNext().catch(() => { });
|
|
332
|
+
});
|
|
333
|
+
// Server→client: pass through unmodified
|
|
334
|
+
upSocket.on('data', (c) => { try {
|
|
335
|
+
clientTls.write(c);
|
|
336
|
+
}
|
|
337
|
+
catch { } });
|
|
338
|
+
});
|
|
339
|
+
clientTls.on('error', () => { try {
|
|
340
|
+
upSocket.destroy();
|
|
341
|
+
}
|
|
342
|
+
catch { } });
|
|
343
|
+
clientTls.on('close', () => { try {
|
|
344
|
+
upSocket.destroy();
|
|
345
|
+
}
|
|
346
|
+
catch { } });
|
|
347
|
+
upSocket.on('close', () => { try {
|
|
348
|
+
clientTls.destroy();
|
|
349
|
+
}
|
|
350
|
+
catch { } });
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// ── Regular HTTP/1.1 (non-WebSocket) ─────────────────────────────────────
|
|
354
|
+
const fakeServer = new http.Server();
|
|
355
|
+
fakeServer.emit('connection', clientTls);
|
|
356
|
+
setImmediate(() => { if (!clientTls.destroyed)
|
|
357
|
+
clientTls.emit('data', firstChunk); });
|
|
358
|
+
fakeServer.on('request', (clientReq, clientRes) => {
|
|
359
|
+
const headers = {};
|
|
360
|
+
for (const [k, v] of Object.entries(clientReq.headers)) {
|
|
361
|
+
if (/^[\w\-]+$/.test(k))
|
|
362
|
+
headers[k] = Array.isArray(v) ? v.join(', ') : (v ?? '');
|
|
363
|
+
}
|
|
364
|
+
headers['host'] = hostname;
|
|
365
|
+
// Capture account-id from HTTP requests too
|
|
366
|
+
if (clientReq.headers['chatgpt-account-id'] && !accountId) {
|
|
367
|
+
accountId = String(clientReq.headers['chatgpt-account-id']);
|
|
368
|
+
}
|
|
369
|
+
const upReq = https.request({
|
|
370
|
+
hostname, port,
|
|
371
|
+
path: clientReq.url ?? '/',
|
|
372
|
+
method: clientReq.method ?? 'GET',
|
|
373
|
+
headers,
|
|
374
|
+
}, (upRes) => {
|
|
375
|
+
clientRes.writeHead(upRes.statusCode ?? 200, upRes.headers);
|
|
376
|
+
upRes.pipe(clientRes);
|
|
377
|
+
});
|
|
378
|
+
upReq.on('error', () => { try {
|
|
379
|
+
clientRes.destroy();
|
|
380
|
+
}
|
|
381
|
+
catch { } });
|
|
382
|
+
clientReq.pipe(upReq);
|
|
383
|
+
});
|
|
384
|
+
fakeServer.on('error', () => { try {
|
|
385
|
+
clientTls.destroy();
|
|
386
|
+
}
|
|
387
|
+
catch { } });
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// ── Plain HTTP handler ────────────────────────────────────────────────────────
|
|
391
|
+
function handleHttp(req, res) {
|
|
392
|
+
const upReq = http.request({
|
|
393
|
+
hostname: req.headers.host?.split(':')[0] ?? 'localhost',
|
|
394
|
+
port: 80,
|
|
395
|
+
path: req.url,
|
|
396
|
+
method: req.method,
|
|
397
|
+
headers: req.headers,
|
|
398
|
+
}, (upRes) => {
|
|
399
|
+
res.writeHead(upRes.statusCode ?? 200, upRes.headers);
|
|
400
|
+
upRes.pipe(res);
|
|
401
|
+
});
|
|
402
|
+
upReq.on('error', () => res.writeHead(502).end());
|
|
403
|
+
req.pipe(upReq);
|
|
404
|
+
}
|
|
405
|
+
// ── Server lifecycle ──────────────────────────────────────────────────────────
|
|
406
|
+
let mitmServer = null;
|
|
407
|
+
export function startMitmProxy() {
|
|
408
|
+
try {
|
|
409
|
+
ensureCA();
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
console.error('[squeezr/mitm] CA generation failed:', err);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
mitmServer = http.createServer(handleHttp);
|
|
416
|
+
mitmServer.on('connect', handleConnect);
|
|
417
|
+
mitmServer.on('error', (err) => {
|
|
418
|
+
if (err.code !== 'EADDRINUSE')
|
|
419
|
+
console.error('[squeezr/mitm] error:', err.message);
|
|
420
|
+
});
|
|
421
|
+
mitmServer.listen(MITM_PORT, () => {
|
|
422
|
+
console.log(`[squeezr/mitm] HTTPS proxy on http://localhost:${MITM_PORT}`);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
export function stopMitmProxy() {
|
|
426
|
+
mitmServer?.close();
|
|
427
|
+
mitmServer = null;
|
|
428
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { serve } from '@hono/node-server';
|
|
1
|
+
import { createAdaptorServer } from '@hono/node-server';
|
|
3
2
|
import { app, stats } from './server.js';
|
|
4
3
|
import { config } from './config.js';
|
|
5
4
|
import { VERSION } from './version.js';
|
|
5
|
+
import { startMitmProxy } from './codexMitm.js';
|
|
6
6
|
const PORT = config.port;
|
|
7
|
-
const
|
|
7
|
+
const httpServer = createAdaptorServer({ fetch: app.fetch });
|
|
8
|
+
httpServer.listen(PORT, () => {
|
|
8
9
|
console.log(`Squeezr v${VERSION} listening on http://localhost:${PORT}`);
|
|
9
10
|
console.log(`Mode: ${config.dryRun ? 'dry-run' : 'active'}`);
|
|
10
11
|
if (config.disabled)
|
|
@@ -12,38 +13,14 @@ const server = serve({ fetch: app.fetch, port: PORT }, () => {
|
|
|
12
13
|
console.log(`Backends: Anthropic → Haiku | OpenAI → GPT-4o-mini | Gemini → Flash-8B | Local → ${config.localCompressionModel}`);
|
|
13
14
|
console.log(`Stats: http://localhost:${PORT}/squeezr/stats`);
|
|
14
15
|
});
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
socket.destroy();
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
const targetHost = 'api.openai.com';
|
|
21
|
-
const targetPath = '/v1/responses';
|
|
22
|
-
const upstream = tls.connect({ host: targetHost, port: 443, servername: targetHost }, () => {
|
|
23
|
-
// Rebuild the HTTP upgrade request with the correct host and path
|
|
24
|
-
const fwdHeaders = Object.entries(req.headers)
|
|
25
|
-
.filter(([k]) => k.toLowerCase() !== 'host')
|
|
26
|
-
.map(([k, v]) => `${k}: ${v}`)
|
|
27
|
-
.join('\r\n');
|
|
28
|
-
const upgradeReq = `GET ${targetPath} HTTP/1.1\r\nHost: ${targetHost}\r\n${fwdHeaders}\r\n\r\n`;
|
|
29
|
-
upstream.write(upgradeReq);
|
|
30
|
-
if (head.length > 0)
|
|
31
|
-
upstream.write(head);
|
|
32
|
-
upstream.pipe(socket);
|
|
33
|
-
socket.pipe(upstream);
|
|
34
|
-
});
|
|
35
|
-
upstream.on('error', () => socket.destroy());
|
|
36
|
-
socket.on('error', () => upstream.destroy());
|
|
37
|
-
});
|
|
16
|
+
// Start MITM proxy for Codex OAuth (chatgpt.com/backend-api)
|
|
17
|
+
startMitmProxy();
|
|
38
18
|
const isDaemon = !!process.env.SQUEEZR_DAEMON;
|
|
39
19
|
if (isDaemon) {
|
|
40
|
-
// Daemon mode: ignore SIGINT (Ctrl+C) and SIGHUP (terminal close)
|
|
41
|
-
// Only stop via `squeezr stop` which sends SIGTERM
|
|
42
20
|
process.on('SIGINT', () => { });
|
|
43
21
|
process.on('SIGHUP', () => { });
|
|
44
22
|
}
|
|
45
23
|
else {
|
|
46
|
-
// Dev mode (npm run dev): allow Ctrl+C to stop
|
|
47
24
|
process.on('SIGINT', () => {
|
|
48
25
|
const s = stats.summary();
|
|
49
26
|
console.log(`\n[squeezr] Session summary: ${s.requests} requests | -${s.total_saved_chars.toLocaleString()} chars (~${s.total_saved_tokens.toLocaleString()} tokens, ${s.savings_pct}% saved)`);
|
package/dist/server.js
CHANGED
|
@@ -232,12 +232,24 @@ app.get('/squeezr/expand/:id', (c) => {
|
|
|
232
232
|
return c.json({ error: 'Not found or expired' }, 404);
|
|
233
233
|
return c.json({ id, content: original });
|
|
234
234
|
});
|
|
235
|
+
// ── OAuth token refresh proxy (Codex: set CODEX_REFRESH_TOKEN_URL_OVERRIDE=http://localhost:PORT/oauth/token) ──
|
|
236
|
+
app.post('/oauth/token', async (c) => {
|
|
237
|
+
const body = await c.req.arrayBuffer();
|
|
238
|
+
const resp = await fetch('https://auth.openai.com/oauth/token', {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'content-type': c.req.header('content-type') ?? 'application/json' },
|
|
241
|
+
body,
|
|
242
|
+
});
|
|
243
|
+
const data = await resp.arrayBuffer();
|
|
244
|
+
return c.body(data, resp.status, { 'content-type': 'application/json' });
|
|
245
|
+
});
|
|
235
246
|
// ── Catch-all ─────────────────────────────────────────────────────────────────
|
|
236
247
|
app.all('*', async (c) => {
|
|
237
248
|
const upstream = detectUpstream(c.req.raw.headers);
|
|
238
249
|
const url = new URL(c.req.url);
|
|
239
|
-
const
|
|
240
|
-
const
|
|
250
|
+
const NEEDS_V1 = new Set(['/models', '/engines', '/files', '/embeddings', '/moderations', '/completions', '/edits']);
|
|
251
|
+
const pathname = NEEDS_V1.has(url.pathname) ? `/v1${url.pathname}` : url.pathname;
|
|
252
|
+
const targetUrl = `${upstream}${pathname}${url.search}`;
|
|
241
253
|
const body = await c.req.arrayBuffer();
|
|
242
254
|
const fwdHeaders = forwardHeaders(c.req.raw.headers);
|
|
243
255
|
const resp = await fetch(targetUrl, {
|
|
@@ -250,5 +262,17 @@ app.all('*', async (c) => {
|
|
|
250
262
|
if (!SKIP_RESP_HEADERS.has(k.toLowerCase()))
|
|
251
263
|
respHeaders[k] = v;
|
|
252
264
|
}
|
|
265
|
+
const contentType = resp.headers.get('content-type') ?? '';
|
|
266
|
+
if (contentType.includes('text/event-stream')) {
|
|
267
|
+
return stream(c, async (s) => {
|
|
268
|
+
const reader = resp.body.getReader();
|
|
269
|
+
while (true) {
|
|
270
|
+
const { done, value } = await reader.read();
|
|
271
|
+
if (done)
|
|
272
|
+
break;
|
|
273
|
+
await s.write(value);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
253
277
|
return c.body(await resp.arrayBuffer(), resp.status, respHeaders);
|
|
254
278
|
});
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.
|
|
1
|
+
export declare const VERSION = "1.13.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '1.
|
|
1
|
+
export const VERSION = '1.13.1';
|
package/package.json
CHANGED
|
@@ -1,58 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "squeezr-ai",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"claude",
|
|
7
|
-
"claude-code",
|
|
8
|
-
"codex",
|
|
9
|
-
"ollama",
|
|
10
|
-
"aider",
|
|
11
|
-
"gemini",
|
|
12
|
-
"token",
|
|
13
|
-
"compression",
|
|
14
|
-
"proxy",
|
|
15
|
-
"llm",
|
|
16
|
-
"ai"
|
|
17
|
-
],
|
|
18
|
-
"license": "MIT",
|
|
19
|
-
"repository": {
|
|
20
|
-
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/sergioramosv/Squeezr.git"
|
|
22
|
-
},
|
|
23
|
-
"homepage": "https://github.com/sergioramosv/Squeezr#readme",
|
|
24
|
-
"type": "module",
|
|
25
|
-
"bin": {
|
|
26
|
-
"squeezr": "bin/squeezr.js"
|
|
27
|
-
},
|
|
28
|
-
"scripts": {
|
|
29
|
-
"build": "tsc",
|
|
30
|
-
"dev": "tsx src/index.ts",
|
|
31
|
-
"start": "node dist/index.js",
|
|
32
|
-
"gain": "node dist/gain.js",
|
|
33
|
-
"discover": "node dist/discover.js",
|
|
34
|
-
"test": "vitest run",
|
|
35
|
-
"test:watch": "vitest"
|
|
36
|
-
},
|
|
37
|
-
"files": [
|
|
38
|
-
"bin/",
|
|
39
|
-
"dist/",
|
|
40
|
-
"squeezr.toml"
|
|
41
|
-
],
|
|
42
|
-
"dependencies": {
|
|
43
|
-
"@anthropic-ai/sdk": "^0.39.0",
|
|
44
|
-
"@hono/node-server": "^1.13.7",
|
|
45
|
-
"hono": "^4.7.5",
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "squeezr-ai",
|
|
3
|
+
"version": "1.13.1",
|
|
4
|
+
"description": "AI proxy that compresses Claude Code, Codex, Aider, Gemini CLI and Ollama context windows to save thousands of tokens per session",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"codex",
|
|
9
|
+
"ollama",
|
|
10
|
+
"aider",
|
|
11
|
+
"gemini",
|
|
12
|
+
"token",
|
|
13
|
+
"compression",
|
|
14
|
+
"proxy",
|
|
15
|
+
"llm",
|
|
16
|
+
"ai"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/sergioramosv/Squeezr.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/sergioramosv/Squeezr#readme",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"squeezr": "bin/squeezr.js"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"dev": "tsx src/index.ts",
|
|
31
|
+
"start": "node dist/index.js",
|
|
32
|
+
"gain": "node dist/gain.js",
|
|
33
|
+
"discover": "node dist/discover.js",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"bin/",
|
|
39
|
+
"dist/",
|
|
40
|
+
"squeezr.toml"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
44
|
+
"@hono/node-server": "^1.13.7",
|
|
45
|
+
"hono": "^4.7.5",
|
|
46
|
+
"node-forge": "^1.4.0",
|
|
47
|
+
"openai": "^4.93.0",
|
|
48
|
+
"smol-toml": "^1.3.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.14.0",
|
|
52
|
+
"@types/node-forge": "^1.3.14",
|
|
53
|
+
"tsx": "^4.19.3",
|
|
54
|
+
"typescript": "^5.8.3",
|
|
55
|
+
"vitest": "^3.1.1"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
}
|
|
60
|
+
}
|