squeezr-ai 1.17.1 → 1.17.5
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 +5 -2
- package/bin/squeezr.js +275 -0
- package/dist/__tests__/cursorMitm.test.d.ts +1 -0
- package/dist/__tests__/cursorMitm.test.js +313 -0
- package/dist/compressor.d.ts +1 -1
- package/dist/compressor.js +27 -15
- package/dist/config.d.ts +16 -0
- package/dist/config.js +28 -0
- package/dist/cursorMitm.d.ts +18 -0
- package/dist/cursorMitm.js +846 -0
- package/dist/dashboard.d.ts +8 -0
- package/dist/dashboard.js +428 -0
- package/dist/expand.d.ts +2 -0
- package/dist/expand.js +26 -0
- package/dist/index.js +40 -6
- package/dist/server.js +67 -7
- package/dist/sessionCache.d.ts +2 -0
- package/dist/sessionCache.js +26 -0
- package/package.json +62 -61
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Token compression proxy for AI coding CLIs.** Sits between your CLI and the API, compresses context on the fly, saves thousands of tokens per session.
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/squeezr-ai) [](LICENSE) [](https://www.npmjs.com/package/squeezr-ai) [](LICENSE) []()
|
|
6
6
|
|
|
7
7
|
## Supported CLIs
|
|
8
8
|
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
| Gemini CLI | HTTP to Gemini API | `GEMINI_API_BASE_URL=http://localhost:8080` |
|
|
15
15
|
| Ollama | HTTP (local) | Transparent via dummy API key detection |
|
|
16
16
|
| **Codex** | **WebSocket to chatgpt.com** | **TLS-terminating MITM proxy on :8081** |
|
|
17
|
+
| **Cursor IDE** | **ConnectRPC/HTTP2 to api2.cursor.sh** | **`squeezr cursor` — MITM proxy on :8082** |
|
|
18
|
+
| Continue (VS Code) | HTTP to OpenAI-compat | `apiBase: http://localhost:8080/v1` |
|
|
17
19
|
|
|
18
20
|
## Quick start
|
|
19
21
|
|
|
@@ -109,8 +111,9 @@ threshold = 800 # min chars to trigger compression
|
|
|
109
111
|
keep_recent = 3 # last N results left uncompressed
|
|
110
112
|
compress_system_prompt = true
|
|
111
113
|
compress_conversation = false # aggressive: compress assistant messages too
|
|
112
|
-
# skip_tools = ["Read"] #
|
|
114
|
+
# skip_tools = ["Read"] # skip ALL compression for these tools (deterministic + AI)
|
|
113
115
|
# only_tools = ["Bash"] # only compress these tools
|
|
116
|
+
ai_skip_tools = ["Read"] # skip AI compression only (default); deterministic still runs
|
|
114
117
|
|
|
115
118
|
[cache]
|
|
116
119
|
enabled = true
|
package/bin/squeezr.js
CHANGED
|
@@ -200,6 +200,9 @@ Usage:
|
|
|
200
200
|
squeezr status Check if proxy is running
|
|
201
201
|
squeezr config Print config file path and current settings
|
|
202
202
|
squeezr ports Change HTTP and MITM proxy ports
|
|
203
|
+
squeezr tunnel Expose proxy via Cloudflare Tunnel for Cursor IDE
|
|
204
|
+
squeezr cursor Start Cursor subscription MITM proxy (no BYOK needed)
|
|
205
|
+
squeezr cursor stop Stop Cursor proxy and clean up system proxy settings
|
|
203
206
|
squeezr update Kill old processes, install latest from npm, restart
|
|
204
207
|
squeezr uninstall Remove Squeezr completely (env vars, CA, auto-start, logs)
|
|
205
208
|
squeezr version Print version
|
|
@@ -1150,6 +1153,265 @@ Done!
|
|
|
1150
1153
|
installShellWrapper()
|
|
1151
1154
|
}
|
|
1152
1155
|
|
|
1156
|
+
// ── squeezr tunnel ────────────────────────────────────────────────────────────
|
|
1157
|
+
// Exposes the local proxy via a Cloudflare Quick Tunnel (free, no account needed).
|
|
1158
|
+
// Cursor IDE requires a public HTTPS URL because its servers call the endpoint
|
|
1159
|
+
// from Cloudflare's infrastructure — localhost is unreachable from there.
|
|
1160
|
+
|
|
1161
|
+
async function startTunnel() {
|
|
1162
|
+
const port = getPort()
|
|
1163
|
+
|
|
1164
|
+
// Verify proxy is running first
|
|
1165
|
+
const running = await new Promise(resolve => {
|
|
1166
|
+
const req = http.get(`http://localhost:${port}/squeezr/health`, res => {
|
|
1167
|
+
resolve(res.statusCode === 200)
|
|
1168
|
+
})
|
|
1169
|
+
req.on('error', () => resolve(false))
|
|
1170
|
+
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
|
1171
|
+
})
|
|
1172
|
+
|
|
1173
|
+
if (!running) {
|
|
1174
|
+
console.error(`Squeezr proxy is not running on port ${port}.`)
|
|
1175
|
+
console.error(`Start it first: squeezr start`)
|
|
1176
|
+
process.exit(1)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
console.log(`Starting Cloudflare Quick Tunnel for http://localhost:${port}...`)
|
|
1180
|
+
console.log(`(free, no account needed — powered by trycloudflare.com)\n`)
|
|
1181
|
+
|
|
1182
|
+
// Try cloudflared binary, fall back to npx
|
|
1183
|
+
let tunnelCmd, tunnelArgs
|
|
1184
|
+
try {
|
|
1185
|
+
execSync('cloudflared --version', { stdio: 'pipe' })
|
|
1186
|
+
tunnelCmd = 'cloudflared'
|
|
1187
|
+
tunnelArgs = ['tunnel', '--url', `http://localhost:${port}`]
|
|
1188
|
+
} catch {
|
|
1189
|
+
tunnelCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
|
1190
|
+
tunnelArgs = ['cloudflared@latest', 'tunnel', '--url', `http://localhost:${port}`]
|
|
1191
|
+
console.log(`cloudflared not installed — using npx cloudflared (may take a moment to download)\n`)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
let tunnelUrl = null
|
|
1195
|
+
const child = spawn(tunnelCmd, tunnelArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
1196
|
+
|
|
1197
|
+
const printInstructions = (url) => {
|
|
1198
|
+
console.log(`\n ╔══════════════════════════════════════════════════════════════════╗`)
|
|
1199
|
+
console.log(` ║ Tunnel active: ${url.padEnd(49)}║`)
|
|
1200
|
+
console.log(` ╠══════════════════════════════════════════════════════════════════╣`)
|
|
1201
|
+
console.log(` ║ CURSOR SETUP ║`)
|
|
1202
|
+
console.log(` ║ ║`)
|
|
1203
|
+
console.log(` ║ 1. Cursor → Settings → Models ║`)
|
|
1204
|
+
console.log(` ║ 2. Add your OpenAI or Anthropic API key ║`)
|
|
1205
|
+
console.log(` ║ 3. Enable "Override OpenAI Base URL" ║`)
|
|
1206
|
+
console.log(` ║ 4. Set URL to: ${(url + '/v1').padEnd(49)}║`)
|
|
1207
|
+
console.log(` ║ 5. Disable all built-in Cursor models ║`)
|
|
1208
|
+
console.log(` ║ 6. Add a custom model pointing to the same URL ║`)
|
|
1209
|
+
console.log(` ║ ║`)
|
|
1210
|
+
console.log(` ║ CONTINUE EXTENSION (VS Code / JetBrains) ║`)
|
|
1211
|
+
console.log(` ║ No tunnel needed — use http://localhost:${port} directly ${' '.repeat(Math.max(0, 17 - String(port).length))}║`)
|
|
1212
|
+
console.log(` ║ ║`)
|
|
1213
|
+
console.log(` ║ Press Ctrl+C to stop the tunnel ║`)
|
|
1214
|
+
console.log(` ╚══════════════════════════════════════════════════════════════════╝\n`)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const parseUrl = (line) => {
|
|
1218
|
+
const m = line.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/)
|
|
1219
|
+
return m ? m[0] : null
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
child.stdout.on('data', (chunk) => {
|
|
1223
|
+
const text = chunk.toString()
|
|
1224
|
+
if (!tunnelUrl) {
|
|
1225
|
+
const found = parseUrl(text)
|
|
1226
|
+
if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
|
|
1227
|
+
}
|
|
1228
|
+
process.stdout.write(text)
|
|
1229
|
+
})
|
|
1230
|
+
|
|
1231
|
+
child.stderr.on('data', (chunk) => {
|
|
1232
|
+
const text = chunk.toString()
|
|
1233
|
+
if (!tunnelUrl) {
|
|
1234
|
+
const found = parseUrl(text)
|
|
1235
|
+
if (found) { tunnelUrl = found; printInstructions(tunnelUrl) }
|
|
1236
|
+
}
|
|
1237
|
+
// Only show cloudflared logs if no URL yet (suppress verbose after)
|
|
1238
|
+
if (!tunnelUrl) process.stderr.write(text)
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
child.on('error', (err) => {
|
|
1242
|
+
if (err.code === 'ENOENT') {
|
|
1243
|
+
console.error(`Could not start tunnel. Install cloudflared: https://developers.cloudflare.com/cloudflared/downloads`)
|
|
1244
|
+
} else {
|
|
1245
|
+
console.error(`Tunnel error: ${err.message}`)
|
|
1246
|
+
}
|
|
1247
|
+
process.exit(1)
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
child.on('exit', (code) => {
|
|
1251
|
+
if (code !== 0) console.log(`\nTunnel stopped (exit ${code})`)
|
|
1252
|
+
process.exit(0)
|
|
1253
|
+
})
|
|
1254
|
+
|
|
1255
|
+
process.on('SIGINT', () => { child.kill(); process.exit(0) })
|
|
1256
|
+
process.on('SIGTERM', () => { child.kill(); process.exit(0) })
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// ── squeezr cursor ───────────────────────────────────────────────────────────
|
|
1260
|
+
|
|
1261
|
+
async function startCursorProxy() {
|
|
1262
|
+
const port = getPort()
|
|
1263
|
+
const mitmPort = getMitmPort(port)
|
|
1264
|
+
|
|
1265
|
+
// Verify main proxy is running first
|
|
1266
|
+
const running = await new Promise(resolve => {
|
|
1267
|
+
const req = http.get(`http://localhost:${port}/squeezr/health`, res => {
|
|
1268
|
+
resolve(res.statusCode === 200)
|
|
1269
|
+
})
|
|
1270
|
+
req.on('error', () => resolve(false))
|
|
1271
|
+
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
if (!running) {
|
|
1275
|
+
console.error(`Squeezr proxy is not running on port ${port}.`)
|
|
1276
|
+
console.error('Start it first: squeezr start')
|
|
1277
|
+
process.exit(1)
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Verify CA exists
|
|
1281
|
+
const caDir = path.join(os.homedir(), '.squeezr', 'mitm-ca')
|
|
1282
|
+
const caCertPath = path.join(caDir, 'ca.crt')
|
|
1283
|
+
if (!fs.existsSync(caCertPath)) {
|
|
1284
|
+
console.error('MITM CA certificate not found. Run `squeezr setup` first.')
|
|
1285
|
+
process.exit(1)
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
console.log('Starting Cursor MITM proxy...')
|
|
1289
|
+
|
|
1290
|
+
const distPath = path.join(ROOT, 'dist', 'cursorMitm.js')
|
|
1291
|
+
if (!fs.existsSync(distPath)) {
|
|
1292
|
+
console.error(`Error: ${distPath} not found. Run 'npm run build' first.`)
|
|
1293
|
+
process.exit(1)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const distUrl = process.platform === 'win32' ? 'file:///' + distPath.replace(/\\/g, '/') : distPath
|
|
1297
|
+
const { startCursorMitm, getCursorMitmPort, getCursorStats } = await import(distUrl)
|
|
1298
|
+
|
|
1299
|
+
try {
|
|
1300
|
+
await startCursorMitm()
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
console.error('Failed to start Cursor proxy:', err.message)
|
|
1303
|
+
process.exit(1)
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const actualPort = getCursorMitmPort()
|
|
1307
|
+
const proxyConfigured = configureSystemProxy(actualPort)
|
|
1308
|
+
|
|
1309
|
+
console.log('')
|
|
1310
|
+
console.log('╔══════════════════════════════════════════════════════════════════╗')
|
|
1311
|
+
console.log(`║ Cursor MITM proxy active on port ${actualPort} ║`)
|
|
1312
|
+
console.log('╠══════════════════════════════════════════════════════════════════╣')
|
|
1313
|
+
console.log('║ ║')
|
|
1314
|
+
console.log('║ Intercepting: api2.cursor.sh (chat, agent, composer) ║')
|
|
1315
|
+
console.log('║ Compressing: conversation context via cursor-small ║')
|
|
1316
|
+
console.log('║ Everything else: transparent pass-through ║')
|
|
1317
|
+
console.log('║ ║')
|
|
1318
|
+
if (proxyConfigured) {
|
|
1319
|
+
console.log('║ System proxy configured automatically. ║')
|
|
1320
|
+
console.log('║ Cursor will route through Squeezr on next request. ║')
|
|
1321
|
+
} else {
|
|
1322
|
+
console.log('║ ⚠ Could not set system proxy automatically. ║')
|
|
1323
|
+
console.log('║ Set it manually: ║')
|
|
1324
|
+
if (process.platform === 'win32') {
|
|
1325
|
+
console.log(`║ Settings > Network > Proxy > Manual: 127.0.0.1:${actualPort} ║`)
|
|
1326
|
+
} else if (process.platform === 'darwin') {
|
|
1327
|
+
console.log(`║ networksetup -setsecurewebproxy Wi-Fi 127.0.0.1 ${actualPort} ║`)
|
|
1328
|
+
} else {
|
|
1329
|
+
console.log(`║ export HTTPS_PROXY=http://127.0.0.1:${actualPort} ║`)
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
console.log('║ ║')
|
|
1333
|
+
console.log('║ Press Ctrl+C to stop and clean up proxy settings ║')
|
|
1334
|
+
console.log('╚══════════════════════════════════════════════════════════════════╝')
|
|
1335
|
+
console.log('')
|
|
1336
|
+
|
|
1337
|
+
const statsInterval = setInterval(() => {
|
|
1338
|
+
const s = getCursorStats()
|
|
1339
|
+
if (s.requests > 0) {
|
|
1340
|
+
console.log(`[squeezr/cursor] Stats: ${s.requests} requests, ${s.compressed} compressed, -${s.charsSaved.toLocaleString()} chars saved`)
|
|
1341
|
+
}
|
|
1342
|
+
}, 30_000)
|
|
1343
|
+
|
|
1344
|
+
const cleanup = () => {
|
|
1345
|
+
clearInterval(statsInterval)
|
|
1346
|
+
console.log('\nStopping Cursor proxy...')
|
|
1347
|
+
cleanSystemProxy()
|
|
1348
|
+
const s = getCursorStats()
|
|
1349
|
+
console.log(`Session: ${s.requests} requests, ${s.compressed} compressed, -${s.charsSaved.toLocaleString()} chars saved`)
|
|
1350
|
+
process.exit(0)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
process.on('SIGINT', cleanup)
|
|
1354
|
+
process.on('SIGTERM', cleanup)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function configureSystemProxy(port) {
|
|
1358
|
+
try {
|
|
1359
|
+
if (process.platform === 'win32') {
|
|
1360
|
+
execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyEnable /t REG_DWORD /d 1 /f`, { stdio: 'pipe' })
|
|
1361
|
+
execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyServer /t REG_SZ /d "127.0.0.1:${port}" /f`, { stdio: 'pipe' })
|
|
1362
|
+
execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyOverride /t REG_SZ /d "<local>;localhost;127.0.0.1" /f`, { stdio: 'pipe' })
|
|
1363
|
+
return true
|
|
1364
|
+
} else if (process.platform === 'darwin') {
|
|
1365
|
+
try {
|
|
1366
|
+
const services = execSync('networksetup -listallnetworkservices', { encoding: 'utf-8' })
|
|
1367
|
+
.split('\n')
|
|
1368
|
+
.filter(s => s.trim() && !s.startsWith('*') && !s.startsWith('An asterisk'))
|
|
1369
|
+
for (const svc of services) {
|
|
1370
|
+
try {
|
|
1371
|
+
execSync(`networksetup -setsecurewebproxy "${svc.trim()}" 127.0.0.1 ${port}`, { stdio: 'pipe' })
|
|
1372
|
+
} catch {}
|
|
1373
|
+
}
|
|
1374
|
+
return true
|
|
1375
|
+
} catch { return false }
|
|
1376
|
+
} else {
|
|
1377
|
+
try {
|
|
1378
|
+
execSync(`gsettings set org.gnome.system.proxy mode 'manual'`, { stdio: 'pipe' })
|
|
1379
|
+
execSync(`gsettings set org.gnome.system.proxy.https host '127.0.0.1'`, { stdio: 'pipe' })
|
|
1380
|
+
execSync(`gsettings set org.gnome.system.proxy.https port ${port}`, { stdio: 'pipe' })
|
|
1381
|
+
return true
|
|
1382
|
+
} catch { return false }
|
|
1383
|
+
}
|
|
1384
|
+
} catch {
|
|
1385
|
+
return false
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function cleanSystemProxy() {
|
|
1390
|
+
try {
|
|
1391
|
+
if (process.platform === 'win32') {
|
|
1392
|
+
execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings" /v ProxyEnable /t REG_DWORD /d 0 /f`, { stdio: 'pipe' })
|
|
1393
|
+
} else if (process.platform === 'darwin') {
|
|
1394
|
+
try {
|
|
1395
|
+
const services = execSync('networksetup -listallnetworkservices', { encoding: 'utf-8' })
|
|
1396
|
+
.split('\n')
|
|
1397
|
+
.filter(s => s.trim() && !s.startsWith('*') && !s.startsWith('An asterisk'))
|
|
1398
|
+
for (const svc of services) {
|
|
1399
|
+
try {
|
|
1400
|
+
execSync(`networksetup -setsecurewebproxystate "${svc.trim()}" off`, { stdio: 'pipe' })
|
|
1401
|
+
} catch {}
|
|
1402
|
+
}
|
|
1403
|
+
} catch {}
|
|
1404
|
+
} else {
|
|
1405
|
+
try {
|
|
1406
|
+
execSync(`gsettings set org.gnome.system.proxy mode 'none'`, { stdio: 'pipe' })
|
|
1407
|
+
} catch {}
|
|
1408
|
+
}
|
|
1409
|
+
console.log('System proxy settings cleaned up.')
|
|
1410
|
+
} catch {
|
|
1411
|
+
console.warn('Could not clean system proxy settings. You may need to disable manually.')
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1153
1415
|
// ── CLI router ────────────────────────────────────────────────────────────────
|
|
1154
1416
|
|
|
1155
1417
|
switch (command) {
|
|
@@ -1262,6 +1524,19 @@ switch (command) {
|
|
|
1262
1524
|
case 'ports':
|
|
1263
1525
|
await configurePorts()
|
|
1264
1526
|
break
|
|
1527
|
+
|
|
1528
|
+
case 'tunnel':
|
|
1529
|
+
await startTunnel()
|
|
1530
|
+
break
|
|
1531
|
+
|
|
1532
|
+
case 'cursor':
|
|
1533
|
+
if (args[1] === 'stop') {
|
|
1534
|
+
cleanSystemProxy()
|
|
1535
|
+
} else {
|
|
1536
|
+
await startCursorProxy()
|
|
1537
|
+
}
|
|
1538
|
+
break
|
|
1539
|
+
|
|
1265
1540
|
case 'uninstall':
|
|
1266
1541
|
await uninstall()
|
|
1267
1542
|
break
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
// We test the exported utility functions and the ConnectRPC/proto layer.
|
|
3
|
+
// Since cursorMitm.ts has many private functions, we use a test helper approach
|
|
4
|
+
// by importing the module and testing the public API + running integration checks.
|
|
5
|
+
// For testing the proto encoder/decoder, we replicate the minimal logic here
|
|
6
|
+
// since the functions are module-private. We'll also test the full flow.
|
|
7
|
+
// ── ConnectRPC frame tests ───────────────────────────────────────────────────
|
|
8
|
+
function buildConnectFrame(payload, flag = 0) {
|
|
9
|
+
const header = Buffer.alloc(5);
|
|
10
|
+
header[0] = flag;
|
|
11
|
+
header.writeUInt32BE(payload.length, 1);
|
|
12
|
+
return Buffer.concat([header, payload]);
|
|
13
|
+
}
|
|
14
|
+
function parseConnectFrame(buf) {
|
|
15
|
+
if (buf.length < 5)
|
|
16
|
+
return null;
|
|
17
|
+
const flag = buf[0];
|
|
18
|
+
const length = buf.readUInt32BE(1);
|
|
19
|
+
if (buf.length < 5 + length)
|
|
20
|
+
return null;
|
|
21
|
+
return { flag, payload: buf.subarray(5, 5 + length), total: 5 + length };
|
|
22
|
+
}
|
|
23
|
+
describe('ConnectRPC frame encoding', () => {
|
|
24
|
+
it('should round-trip a simple payload', () => {
|
|
25
|
+
const payload = Buffer.from('hello world');
|
|
26
|
+
const frame = buildConnectFrame(payload);
|
|
27
|
+
expect(frame.length).toBe(5 + payload.length);
|
|
28
|
+
expect(frame[0]).toBe(0); // uncompressed flag
|
|
29
|
+
const parsed = parseConnectFrame(frame);
|
|
30
|
+
expect(parsed).not.toBeNull();
|
|
31
|
+
expect(parsed.flag).toBe(0);
|
|
32
|
+
expect(parsed.payload.toString()).toBe('hello world');
|
|
33
|
+
expect(parsed.total).toBe(frame.length);
|
|
34
|
+
});
|
|
35
|
+
it('should handle gzip flag', () => {
|
|
36
|
+
const payload = Buffer.from('compressed data');
|
|
37
|
+
const frame = buildConnectFrame(payload, 1);
|
|
38
|
+
const parsed = parseConnectFrame(frame);
|
|
39
|
+
expect(parsed.flag).toBe(1);
|
|
40
|
+
expect(parsed.payload.toString()).toBe('compressed data');
|
|
41
|
+
});
|
|
42
|
+
it('should return null for incomplete frames', () => {
|
|
43
|
+
expect(parseConnectFrame(Buffer.alloc(3))).toBeNull();
|
|
44
|
+
// Header says 100 bytes but only 10 present
|
|
45
|
+
const incomplete = Buffer.alloc(15);
|
|
46
|
+
incomplete.writeUInt32BE(100, 1);
|
|
47
|
+
expect(parseConnectFrame(incomplete)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it('should handle empty payload', () => {
|
|
50
|
+
const frame = buildConnectFrame(Buffer.alloc(0));
|
|
51
|
+
expect(frame.length).toBe(5);
|
|
52
|
+
const parsed = parseConnectFrame(frame);
|
|
53
|
+
expect(parsed.payload.length).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
it('should handle large payloads', () => {
|
|
56
|
+
const payload = Buffer.alloc(65536, 0x42);
|
|
57
|
+
const frame = buildConnectFrame(payload);
|
|
58
|
+
const parsed = parseConnectFrame(frame);
|
|
59
|
+
expect(parsed.payload.length).toBe(65536);
|
|
60
|
+
expect(parsed.payload[0]).toBe(0x42);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
// ── Protobuf varint tests ────────────────────────────────────────────────────
|
|
64
|
+
function encodeVarint(value) {
|
|
65
|
+
const bytes = [];
|
|
66
|
+
while (value > 0x7F) {
|
|
67
|
+
bytes.push((value & 0x7F) | 0x80);
|
|
68
|
+
value >>>= 7;
|
|
69
|
+
}
|
|
70
|
+
bytes.push(value & 0x7F);
|
|
71
|
+
return Buffer.from(bytes);
|
|
72
|
+
}
|
|
73
|
+
function decodeVarint(buf, offset) {
|
|
74
|
+
let value = 0;
|
|
75
|
+
let shift = 0;
|
|
76
|
+
let bytesRead = 0;
|
|
77
|
+
while (offset + bytesRead < buf.length) {
|
|
78
|
+
const byte = buf[offset + bytesRead];
|
|
79
|
+
value |= (byte & 0x7F) << shift;
|
|
80
|
+
bytesRead++;
|
|
81
|
+
if ((byte & 0x80) === 0)
|
|
82
|
+
break;
|
|
83
|
+
shift += 7;
|
|
84
|
+
}
|
|
85
|
+
return { value, bytesRead };
|
|
86
|
+
}
|
|
87
|
+
describe('Protobuf varint encoding', () => {
|
|
88
|
+
it('should encode small numbers', () => {
|
|
89
|
+
expect(encodeVarint(0)).toEqual(Buffer.from([0]));
|
|
90
|
+
expect(encodeVarint(1)).toEqual(Buffer.from([1]));
|
|
91
|
+
expect(encodeVarint(127)).toEqual(Buffer.from([127]));
|
|
92
|
+
});
|
|
93
|
+
it('should encode multi-byte varints', () => {
|
|
94
|
+
expect(encodeVarint(128)).toEqual(Buffer.from([0x80, 0x01]));
|
|
95
|
+
expect(encodeVarint(300)).toEqual(Buffer.from([0xAC, 0x02]));
|
|
96
|
+
});
|
|
97
|
+
it('should round-trip varints', () => {
|
|
98
|
+
for (const val of [0, 1, 127, 128, 300, 16383, 16384, 65535]) {
|
|
99
|
+
const buf = encodeVarint(val);
|
|
100
|
+
const { value } = decodeVarint(buf, 0);
|
|
101
|
+
expect(value).toBe(val);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
// ── Protobuf message building/parsing ────────────────────────────────────────
|
|
106
|
+
function encodeTag(fieldNumber, wireType) {
|
|
107
|
+
return encodeVarint((fieldNumber << 3) | wireType);
|
|
108
|
+
}
|
|
109
|
+
function encodeLengthDelimited(fieldNumber, data) {
|
|
110
|
+
const tag = encodeTag(fieldNumber, 2);
|
|
111
|
+
const len = encodeVarint(data.length);
|
|
112
|
+
return Buffer.concat([tag, len, data]);
|
|
113
|
+
}
|
|
114
|
+
function encodeString(fieldNumber, str) {
|
|
115
|
+
return encodeLengthDelimited(fieldNumber, Buffer.from(str, 'utf-8'));
|
|
116
|
+
}
|
|
117
|
+
function encodeVarintField(fieldNumber, value) {
|
|
118
|
+
const tag = encodeTag(fieldNumber, 0);
|
|
119
|
+
const val = encodeVarint(value);
|
|
120
|
+
return Buffer.concat([tag, val]);
|
|
121
|
+
}
|
|
122
|
+
function parseProtoFields(buf) {
|
|
123
|
+
const fields = [];
|
|
124
|
+
let offset = 0;
|
|
125
|
+
while (offset < buf.length) {
|
|
126
|
+
const tagStart = offset;
|
|
127
|
+
const { value: tag, bytesRead: tagBytes } = decodeVarint(buf, offset);
|
|
128
|
+
offset += tagBytes;
|
|
129
|
+
const fieldNumber = tag >>> 3;
|
|
130
|
+
const wireType = tag & 0x07;
|
|
131
|
+
let fieldEnd = offset;
|
|
132
|
+
switch (wireType) {
|
|
133
|
+
case 0: {
|
|
134
|
+
while (fieldEnd < buf.length && (buf[fieldEnd] & 0x80) !== 0)
|
|
135
|
+
fieldEnd++;
|
|
136
|
+
fieldEnd++;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 1: {
|
|
140
|
+
fieldEnd += 8;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case 2: {
|
|
144
|
+
const { value: len, bytesRead: lenBytes } = decodeVarint(buf, offset);
|
|
145
|
+
fieldEnd = offset + lenBytes + len;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case 5: {
|
|
149
|
+
fieldEnd += 4;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
default: fieldEnd = buf.length;
|
|
153
|
+
}
|
|
154
|
+
fields.push({ fieldNumber, wireType, data: buf.subarray(tagStart, fieldEnd) });
|
|
155
|
+
offset = fieldEnd;
|
|
156
|
+
}
|
|
157
|
+
return fields;
|
|
158
|
+
}
|
|
159
|
+
function extractPayload(rawField) {
|
|
160
|
+
let offset = 0;
|
|
161
|
+
while (offset < rawField.length && (rawField[offset] & 0x80) !== 0)
|
|
162
|
+
offset++;
|
|
163
|
+
offset++;
|
|
164
|
+
const { value: len, bytesRead } = decodeVarint(rawField, offset);
|
|
165
|
+
offset += bytesRead;
|
|
166
|
+
return rawField.subarray(offset, offset + len);
|
|
167
|
+
}
|
|
168
|
+
describe('Protobuf field encoding/parsing', () => {
|
|
169
|
+
it('should encode and parse a string field', () => {
|
|
170
|
+
const encoded = encodeString(2, 'Hello, world!');
|
|
171
|
+
const fields = parseProtoFields(encoded);
|
|
172
|
+
expect(fields.length).toBe(1);
|
|
173
|
+
expect(fields[0].fieldNumber).toBe(2);
|
|
174
|
+
expect(fields[0].wireType).toBe(2);
|
|
175
|
+
const payload = extractPayload(fields[0].data);
|
|
176
|
+
expect(payload.toString('utf-8')).toBe('Hello, world!');
|
|
177
|
+
});
|
|
178
|
+
it('should encode and parse a varint field', () => {
|
|
179
|
+
const encoded = encodeVarintField(1, 42);
|
|
180
|
+
const fields = parseProtoFields(encoded);
|
|
181
|
+
expect(fields.length).toBe(1);
|
|
182
|
+
expect(fields[0].fieldNumber).toBe(1);
|
|
183
|
+
expect(fields[0].wireType).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
it('should encode and parse a ConversationMessage-like structure', () => {
|
|
186
|
+
// ConversationMessage: field 1 = role (varint), field 2 = text (string)
|
|
187
|
+
const msgPayload = Buffer.concat([
|
|
188
|
+
encodeVarintField(1, 1), // role = HUMAN
|
|
189
|
+
encodeString(2, 'What is 2+2?'),
|
|
190
|
+
]);
|
|
191
|
+
const conversationField = encodeLengthDelimited(2, msgPayload);
|
|
192
|
+
const outerFields = parseProtoFields(conversationField);
|
|
193
|
+
expect(outerFields.length).toBe(1);
|
|
194
|
+
expect(outerFields[0].fieldNumber).toBe(2);
|
|
195
|
+
const innerPayload = extractPayload(outerFields[0].data);
|
|
196
|
+
const innerFields = parseProtoFields(innerPayload);
|
|
197
|
+
expect(innerFields.length).toBe(2);
|
|
198
|
+
expect(innerFields[0].fieldNumber).toBe(1); // role
|
|
199
|
+
expect(innerFields[1].fieldNumber).toBe(2); // text
|
|
200
|
+
const text = extractPayload(innerFields[1].data).toString('utf-8');
|
|
201
|
+
expect(text).toBe('What is 2+2?');
|
|
202
|
+
});
|
|
203
|
+
it('should handle a full GetChatRequest with multiple conversation messages', () => {
|
|
204
|
+
// Build a fake GetChatRequest:
|
|
205
|
+
// field 2 (conversation) repeated, field 5 (workspace_root_path)
|
|
206
|
+
const msg1 = encodeLengthDelimited(2, Buffer.concat([
|
|
207
|
+
encodeVarintField(1, 1),
|
|
208
|
+
encodeString(2, 'Please review my code'),
|
|
209
|
+
]));
|
|
210
|
+
const msg2 = encodeLengthDelimited(2, Buffer.concat([
|
|
211
|
+
encodeVarintField(1, 2),
|
|
212
|
+
encodeString(2, 'Sure, I see several issues with your implementation...'),
|
|
213
|
+
]));
|
|
214
|
+
const msg3 = encodeLengthDelimited(2, Buffer.concat([
|
|
215
|
+
encodeVarintField(1, 1),
|
|
216
|
+
encodeString(2, 'Can you fix them?'),
|
|
217
|
+
]));
|
|
218
|
+
const workspacePath = encodeString(5, '/home/user/project');
|
|
219
|
+
const request = Buffer.concat([msg1, msg2, msg3, workspacePath]);
|
|
220
|
+
const fields = parseProtoFields(request);
|
|
221
|
+
// Should have 4 fields: 3 conversation + 1 workspace
|
|
222
|
+
expect(fields.length).toBe(4);
|
|
223
|
+
const conversationFields = fields.filter(f => f.fieldNumber === 2);
|
|
224
|
+
expect(conversationFields.length).toBe(3);
|
|
225
|
+
const otherFields = fields.filter(f => f.fieldNumber !== 2);
|
|
226
|
+
expect(otherFields.length).toBe(1);
|
|
227
|
+
expect(otherFields[0].fieldNumber).toBe(5);
|
|
228
|
+
});
|
|
229
|
+
it('should preserve round-trip of unknown fields', () => {
|
|
230
|
+
// Fields we don't know should survive parse → concat(data) unchanged
|
|
231
|
+
const msg1 = encodeLengthDelimited(2, Buffer.concat([
|
|
232
|
+
encodeVarintField(1, 1),
|
|
233
|
+
encodeString(2, 'hello'),
|
|
234
|
+
]));
|
|
235
|
+
const unknownField1 = encodeString(7, 'some-request-id');
|
|
236
|
+
const unknownField2 = encodeVarintField(27, 1); // is_composer
|
|
237
|
+
const unknownField3 = encodeString(15, 'conv-id-123');
|
|
238
|
+
const original = Buffer.concat([msg1, unknownField1, unknownField2, unknownField3]);
|
|
239
|
+
const fields = parseProtoFields(original);
|
|
240
|
+
// Reconstructing from raw data should give same bytes
|
|
241
|
+
const reconstructed = Buffer.concat(fields.map(f => f.data));
|
|
242
|
+
expect(reconstructed).toEqual(original);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
// ── Integration: ConnectRPC + Protobuf ───────────────────────────────────────
|
|
246
|
+
describe('ConnectRPC + Protobuf integration', () => {
|
|
247
|
+
it('should encode a full request frame and parse it back', () => {
|
|
248
|
+
const msg1 = encodeLengthDelimited(2, Buffer.concat([
|
|
249
|
+
encodeVarintField(1, 1),
|
|
250
|
+
encodeString(2, 'User question'),
|
|
251
|
+
]));
|
|
252
|
+
const msg2 = encodeLengthDelimited(2, Buffer.concat([
|
|
253
|
+
encodeVarintField(1, 2),
|
|
254
|
+
encodeString(2, 'AI response with lots of context and details'),
|
|
255
|
+
]));
|
|
256
|
+
const requestPayload = Buffer.concat([msg1, msg2]);
|
|
257
|
+
const frame = buildConnectFrame(requestPayload);
|
|
258
|
+
// Parse frame
|
|
259
|
+
const parsed = parseConnectFrame(frame);
|
|
260
|
+
expect(parsed).not.toBeNull();
|
|
261
|
+
// Parse proto
|
|
262
|
+
const fields = parseProtoFields(parsed.payload);
|
|
263
|
+
expect(fields.filter(f => f.fieldNumber === 2).length).toBe(2);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
// ── Deterministic compression ────────────────────────────────────────────────
|
|
267
|
+
function deterministicCompress(text) {
|
|
268
|
+
let out = text;
|
|
269
|
+
out = out.replace(/\n{3,}/g, '\n\n');
|
|
270
|
+
out = out.replace(/[ \t]+$/gm, '');
|
|
271
|
+
const lines = out.split('\n');
|
|
272
|
+
const result = [];
|
|
273
|
+
let lastLine = '';
|
|
274
|
+
let repeatCount = 0;
|
|
275
|
+
for (const line of lines) {
|
|
276
|
+
if (line === lastLine && line.trim().length > 0) {
|
|
277
|
+
repeatCount++;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
if (repeatCount > 0) {
|
|
281
|
+
result.push(` ... (repeated ${repeatCount}x)`);
|
|
282
|
+
repeatCount = 0;
|
|
283
|
+
}
|
|
284
|
+
result.push(line);
|
|
285
|
+
lastLine = line;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (repeatCount > 0)
|
|
289
|
+
result.push(` ... (repeated ${repeatCount}x)`);
|
|
290
|
+
return result.join('\n');
|
|
291
|
+
}
|
|
292
|
+
describe('Deterministic compression', () => {
|
|
293
|
+
it('should collapse blank lines', () => {
|
|
294
|
+
const input = 'a\n\n\n\n\nb';
|
|
295
|
+
expect(deterministicCompress(input)).toBe('a\n\nb');
|
|
296
|
+
});
|
|
297
|
+
it('should strip trailing whitespace', () => {
|
|
298
|
+
const input = 'hello \nworld\t\t';
|
|
299
|
+
expect(deterministicCompress(input)).toBe('hello\nworld');
|
|
300
|
+
});
|
|
301
|
+
it('should collapse repeated lines', () => {
|
|
302
|
+
const input = 'error: connection failed\nerror: connection failed\nerror: connection failed\nok';
|
|
303
|
+
const result = deterministicCompress(input);
|
|
304
|
+
expect(result).toContain('error: connection failed');
|
|
305
|
+
expect(result).toContain('repeated 2x');
|
|
306
|
+
expect(result).toContain('ok');
|
|
307
|
+
});
|
|
308
|
+
it('should not collapse empty repeated lines', () => {
|
|
309
|
+
const input = 'a\n\n\nb';
|
|
310
|
+
const result = deterministicCompress(input);
|
|
311
|
+
expect(result).not.toContain('repeated');
|
|
312
|
+
});
|
|
313
|
+
});
|
package/dist/compressor.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ interface AnthropicMessage {
|
|
|
21
21
|
content?: unknown;
|
|
22
22
|
}>;
|
|
23
23
|
}
|
|
24
|
-
export declare function compressAnthropicMessages(messages: AnthropicMessage[], apiKey: string, config: Config): Promise<[AnthropicMessage[], Savings]>;
|
|
24
|
+
export declare function compressAnthropicMessages(messages: AnthropicMessage[], apiKey: string, config: Config, systemExtraChars?: number): Promise<[AnthropicMessage[], Savings]>;
|
|
25
25
|
interface OpenAIMessage {
|
|
26
26
|
role: string;
|
|
27
27
|
content?: string | null;
|