@sliday/tamp 0.1.1 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Tamp
2
2
 
3
- **Token compression proxy for Claude Code.** 33.9% fewer input tokens, zero code changes. Sits between your client and the Anthropic API.
3
+ **Token compression proxy for coding agents.** 33.9% fewer input tokens, zero code changes. Works with Claude Code, Aider, Cursor, Cline, Windsurf, and any OpenAI-compatible agent.
4
4
 
5
5
  ```
6
6
  npx @sliday/tamp
@@ -14,18 +14,28 @@ curl -fsSL https://tamp.dev/setup.sh | bash
14
14
 
15
15
  ## How It Works
16
16
 
17
- Tamp intercepts `POST /v1/messages` requests and compresses `tool_result` blocks before forwarding them upstream. Source code, error results, and non-JSON content pass through untouched.
17
+ Tamp auto-detects your agent's API format and compresses tool result blocks before forwarding upstream. Source code, error results, and non-JSON content pass through untouched.
18
18
 
19
19
  ```
20
20
  Claude Code ──► Tamp (localhost:7778) ──► Anthropic API
21
-
22
- ├─ JSON minify whitespace
23
- ├─ Arrays → TOON columnar encoding
24
- ├─ Line-numberedstrip prefixes + minify
25
- ├─ Source code passthrough
26
- └─ Errorsskip
21
+ Aider/Cursor ──► ──► OpenAI API
22
+ Gemini CLI ────► │ ──► Google AI API
23
+
24
+ ├─ JSONminify whitespace
25
+ ├─ ArraysTOON columnar encoding
26
+ ├─ Line-numberedstrip prefixes + minify
27
+ ├─ Source code → passthrough
28
+ └─ Errors → skip
27
29
  ```
28
30
 
31
+ ### Supported API Formats
32
+
33
+ | Format | Endpoint | Agents |
34
+ |--------|----------|--------|
35
+ | Anthropic Messages | `POST /v1/messages` | Claude Code |
36
+ | OpenAI Chat Completions | `POST /v1/chat/completions` | Aider, Cursor, Cline, Windsurf, OpenCode |
37
+ | Google Gemini | `POST .../generateContent` | Gemini CLI |
38
+
29
39
  ### Compression Stages
30
40
 
31
41
  | Stage | What it does | When it applies |
@@ -45,24 +55,36 @@ npx @sliday/tamp
45
55
  ```
46
56
 
47
57
  ```
48
- ┌─ Tamp ─────────────────────────────┐
49
- │ Proxy: http://localhost:7778
50
- │ Status: ● Ready
51
-
52
- In another terminal:
53
- export ANTHROPIC_BASE_URL=http://localhost:7778
54
- claude
55
- └────────────────────────────────────┘
58
+ ┌─ Tamp ─────────────────────────────────┐
59
+ │ Proxy: http://localhost:7778
60
+ │ Status: ● Ready
61
+
62
+ Claude Code:
63
+ ANTHROPIC_BASE_URL=http://localhost:7778
64
+
65
+ │ Aider / Cursor / Cline: │
66
+ │ OPENAI_BASE_URL=http://localhost:7778
67
+ └────────────────────────────────────────┘
56
68
  ```
57
69
 
58
- ### 2. Point Claude Code at the proxy
70
+ ### 2. Point your agent at the proxy
59
71
 
72
+ **Claude Code:**
60
73
  ```bash
61
74
  export ANTHROPIC_BASE_URL=http://localhost:7778
62
75
  claude
63
76
  ```
64
77
 
65
- That's it. Use Claude Code as normal — Tamp compresses silently in the background.
78
+ **Aider:**
79
+ ```bash
80
+ export OPENAI_API_BASE=http://localhost:7778
81
+ aider
82
+ ```
83
+
84
+ **Cursor / Cline / Windsurf:**
85
+ Set the API base URL to `http://localhost:7778` in your editor's settings.
86
+
87
+ That's it. Use your agent as normal — Tamp compresses silently in the background.
66
88
 
67
89
  ## Configuration
68
90
 
@@ -71,7 +93,9 @@ All configuration via environment variables:
71
93
  | Variable | Default | Description |
72
94
  |----------|---------|-------------|
73
95
  | `TOONA_PORT` | `7778` | Proxy listen port |
74
- | `TOONA_UPSTREAM` | `https://api.anthropic.com` | Upstream API URL |
96
+ | `TOONA_UPSTREAM` | `https://api.anthropic.com` | Default upstream API URL |
97
+ | `TOONA_UPSTREAM_OPENAI` | `https://api.openai.com` | Upstream for OpenAI-format requests |
98
+ | `TOONA_UPSTREAM_GEMINI` | `https://generativelanguage.googleapis.com` | Upstream for Gemini-format requests |
75
99
  | `TOONA_STAGES` | `minify` | Comma-separated compression stages |
76
100
  | `TOONA_MIN_SIZE` | `200` | Minimum content size (chars) to attempt compression |
77
101
  | `TOONA_LOG` | `true` | Enable request logging to stderr |
@@ -136,7 +160,8 @@ Tamp only compresses the **last user message** in each request (the most recent
136
160
  ```
137
161
  bin/tamp.js CLI entry point
138
162
  index.js HTTP proxy server
139
- compress.js Compression pipeline (compressMessages, compressText)
163
+ providers.js API format adapters (Anthropic, OpenAI, Gemini) + auto-detection
164
+ compress.js Compression pipeline (compressRequest, compressText)
140
165
  detect.js Content classification (classifyContent, tryParseJSON, stripLineNumbers)
141
166
  config.js Environment-based configuration
142
167
  stats.js Session statistics and request logging
@@ -145,11 +170,12 @@ setup.sh One-line installer script
145
170
 
146
171
  ### How the proxy works
147
172
 
148
- 1. Non-`/v1/messages` requests are piped through unmodified
149
- 2. `POST /v1/messages` bodies are buffered and parsed as JSON
150
- 3. The last user message's `tool_result` blocks are classified and compressed
151
- 4. The modified body is forwarded upstream with updated `Content-Length`
152
- 5. The upstream response is streamed back to the client unmodified
173
+ 1. `detectProvider()` auto-detects the API format from the request path
174
+ 2. Unrecognized requests are piped through unmodified
175
+ 3. Matched requests are buffered, parsed, and tool results are extracted via the provider adapter
176
+ 4. Extracted blocks are classified and compressed
177
+ 5. The modified body is forwarded to the correct upstream with updated `Content-Length`
178
+ 6. The upstream response is streamed back to the client unmodified
153
179
 
154
180
  Bodies exceeding `TOONA_MAX_BODY` are piped through without buffering.
155
181
 
@@ -183,7 +209,8 @@ node --test test/compress.test.js
183
209
  ### Test files
184
210
 
185
211
  ```
186
- test/compress.test.js Compression pipeline tests
212
+ test/compress.test.js Compression pipeline tests (Anthropic + OpenAI formats)
213
+ test/providers.test.js Provider adapter + auto-detection tests
187
214
  test/detect.test.js Content classification tests
188
215
  test/config.test.js Configuration loading tests
189
216
  test/proxy.test.js HTTP proxy integration tests
package/bin/tamp.js CHANGED
@@ -4,17 +4,23 @@ import { createProxy } from '../index.js'
4
4
  const { config, server } = createProxy()
5
5
 
6
6
  server.listen(config.port, () => {
7
+ const url = `http://localhost:${config.port}`
7
8
  console.error('')
8
- console.error(' ┌─ Tamp ─────────────────────────────┐')
9
- console.error(` │ Proxy: http://localhost:${config.port} │`)
10
- console.error(' │ Status: ● Ready │')
11
- console.error(' │ │')
12
- console.error(' │ In another terminal: │')
13
- console.error(` │ export ANTHROPIC_BASE_URL=http://localhost:${config.port}`)
14
- console.error(' │ claude │')
15
- console.error(' └────────────────────────────────────┘')
9
+ console.error(' ┌─ Tamp ─────────────────────────────────┐')
10
+ console.error(` │ Proxy: ${url} │`)
11
+ console.error(' │ Status: ● Ready │')
12
+ console.error(' │ │')
13
+ console.error(' │ Claude Code: │')
14
+ console.error(` │ ANTHROPIC_BASE_URL=${url} │`)
15
+ console.error(' │ │')
16
+ console.error(' │ Aider / Cursor / Cline: │')
17
+ console.error(` │ OPENAI_BASE_URL=${url} │`)
18
+ console.error(' └────────────────────────────────────────┘')
16
19
  console.error('')
17
- console.error(` Upstream: ${config.upstream}`)
20
+ console.error(` Upstreams:`)
21
+ console.error(` anthropic → ${config.upstreams.anthropic}`)
22
+ console.error(` openai → ${config.upstreams.openai}`)
23
+ console.error(` gemini → ${config.upstreams.gemini}`)
18
24
  console.error(` Stages: ${config.stages.join(', ')}`)
19
25
  console.error('')
20
26
  })
package/compress.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { encode } from '@toon-format/toon'
2
2
  import { countTokens } from '@anthropic-ai/tokenizer'
3
3
  import { tryParseJSON, classifyContent, stripLineNumbers } from './detect.js'
4
+ import { anthropic } from './providers.js'
4
5
 
5
6
  export function compressText(text, config) {
6
7
  if (text.length < config.minSize) return null
@@ -62,54 +63,22 @@ async function compressBlock(text, config) {
62
63
  return sync
63
64
  }
64
65
 
65
- export async function compressMessages(body, config) {
66
+ export async function compressRequest(body, config, provider) {
67
+ const targets = provider.extract(body)
66
68
  const stats = []
67
- if (!body?.messages?.length) return { body, stats }
68
-
69
- let lastUserIdx = -1
70
- for (let i = body.messages.length - 1; i >= 0; i--) {
71
- if (body.messages[i].role === 'user') { lastUserIdx = i; break }
72
- }
73
- if (lastUserIdx === -1) return { body, stats }
74
-
75
- const msg = body.messages[lastUserIdx]
76
- const debug = config.log
77
-
78
- if (typeof msg.content === 'string') {
79
- const result = await compressBlock(msg.content, config)
69
+ for (const target of targets) {
70
+ if (target.skip) { stats.push({ index: target.index, skipped: target.skip }); continue }
71
+ const result = await compressBlock(target.text, config)
80
72
  if (result) {
81
- msg.content = result.text
82
- stats.push({ index: lastUserIdx, ...result })
83
- }
84
- } else if (Array.isArray(msg.content)) {
85
- for (let i = 0; i < msg.content.length; i++) {
86
- const block = msg.content[i]
87
- if (block.type !== 'tool_result') continue
88
- if (block.is_error) { stats.push({ index: i, skipped: 'error' }); continue }
89
-
90
- if (typeof block.content === 'string') {
91
- if (debug) {
92
- const cls = classifyContent(block.content)
93
- const len = block.content.length
94
- console.error(`[toona] debug block[${i}]: type=${cls} len=${len} tool_use_id=${block.tool_use_id || '?'}`)
95
- }
96
- const result = await compressBlock(block.content, config)
97
- if (result) { block.content = result.text; stats.push({ index: i, ...result }) }
98
- } else if (Array.isArray(block.content)) {
99
- for (const sub of block.content) {
100
- if (sub.type === 'text') {
101
- if (debug) {
102
- const cls = classifyContent(sub.text)
103
- const len = sub.text.length
104
- console.error(`[toona] debug sub-block: type=${cls} len=${len}`)
105
- }
106
- const result = await compressBlock(sub.text, config)
107
- if (result) { sub.text = result.text; stats.push({ index: i, ...result }) }
108
- }
109
- }
110
- }
73
+ target.compressed = result.text
74
+ stats.push({ index: target.index, ...result })
111
75
  }
112
76
  }
113
-
77
+ provider.apply(body, targets)
114
78
  return { body, stats }
115
79
  }
80
+
81
+ export async function compressMessages(body, config) {
82
+ if (!body?.messages?.length) return { body, stats: [] }
83
+ return compressRequest(body, config, anthropic)
84
+ }
package/config.js CHANGED
@@ -3,6 +3,11 @@ export function loadConfig(env = process.env) {
3
3
  return Object.freeze({
4
4
  port: parseInt(env.TOONA_PORT, 10) || 7778,
5
5
  upstream: env.TOONA_UPSTREAM || 'https://api.anthropic.com',
6
+ upstreams: Object.freeze({
7
+ anthropic: env.TOONA_UPSTREAM || 'https://api.anthropic.com',
8
+ openai: env.TOONA_UPSTREAM_OPENAI || 'https://api.openai.com',
9
+ gemini: env.TOONA_UPSTREAM_GEMINI || 'https://generativelanguage.googleapis.com',
10
+ }),
6
11
  minSize: parseInt(env.TOONA_MIN_SIZE, 10) || 200,
7
12
  stages,
8
13
  log: env.TOONA_LOG !== 'false',
package/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import http from 'node:http'
2
2
  import https from 'node:https'
3
3
  import { loadConfig } from './config.js'
4
- import { compressMessages } from './compress.js'
4
+ import { compressRequest } from './compress.js'
5
+ import { detectProvider } from './providers.js'
5
6
  import { createSession, formatRequestLog } from './stats.js'
6
7
 
7
8
  export function createProxy(overrides = {}) {
8
- const config = { ...loadConfig(), ...overrides }
9
+ const base = loadConfig()
10
+ const config = { ...base, ...overrides }
11
+ if (overrides.upstream && !overrides.upstreams) {
12
+ config.upstreams = { anthropic: overrides.upstream, openai: overrides.upstream, gemini: overrides.upstream }
13
+ }
9
14
  const session = createSession()
10
15
  return { config, session, server: _createServer(config, session) }
11
16
  }
@@ -81,13 +86,17 @@ function pipeRequest(req, res, upstreamUrl, prefixChunks) {
81
86
 
82
87
  return http.createServer(async (req, res) => {
83
88
  if (config.log) console.error(`[tamp] ${req.method} ${req.url}`)
84
- const upstreamUrl = new URL(req.url, config.upstream)
85
- const isMessages = req.method === 'POST' && req.url.startsWith('/v1/messages')
89
+ const provider = detectProvider(req.method, req.url)
86
90
 
87
- if (!isMessages) {
91
+ if (!provider) {
92
+ const upstreamUrl = new URL(req.url, config.upstream)
88
93
  return pipeRequest(req, res, upstreamUrl)
89
94
  }
90
95
 
96
+ const upstream = config.upstreams?.[provider.name] || config.upstream
97
+ const reqUrl = provider.normalizeUrl ? provider.normalizeUrl(req.url) : req.url
98
+ const upstreamUrl = new URL(reqUrl, upstream)
99
+
91
100
  const chunks = []
92
101
  let size = 0
93
102
  let overflow = false
@@ -113,12 +122,12 @@ return http.createServer(async (req, res) => {
113
122
 
114
123
  try {
115
124
  const parsed = JSON.parse(rawBody.toString('utf-8'))
116
- const { body, stats } = await compressMessages(parsed, config)
125
+ const { body, stats } = await compressRequest(parsed, config, provider)
117
126
  finalBody = Buffer.from(JSON.stringify(body), 'utf-8')
118
127
 
119
128
  if (config.log && stats.length) {
120
129
  session.record(stats)
121
- console.error(formatRequestLog(stats, session))
130
+ console.error(formatRequestLog(stats, session, provider.name, req.url))
122
131
  }
123
132
  } catch (err) {
124
133
  if (config.log) console.error(`[tamp] passthrough (parse error): ${err.message}`)
package/package.json CHANGED
@@ -6,10 +6,11 @@
6
6
  "compress.js",
7
7
  "config.js",
8
8
  "detect.js",
9
+ "providers.js",
9
10
  "stats.js"
10
11
  ],
11
- "version": "0.1.1",
12
- "description": "Token compression proxy for Claude Code. 50% fewer tokens, zero behavior change.",
12
+ "version": "0.2.1",
13
+ "description": "Token compression proxy for coding agents. Works with Claude Code, Aider, Cursor, Cline, Windsurf. 33.9% fewer input tokens.",
13
14
  "type": "module",
14
15
  "main": "index.js",
15
16
  "bin": {
package/providers.js ADDED
@@ -0,0 +1,151 @@
1
+ const anthropic = {
2
+ name: 'anthropic',
3
+ match(method, url) {
4
+ return method === 'POST' && url.startsWith('/v1/messages')
5
+ },
6
+ extract(body) {
7
+ const targets = []
8
+ if (!body?.messages?.length) return targets
9
+
10
+ let lastUserIdx = -1
11
+ for (let i = body.messages.length - 1; i >= 0; i--) {
12
+ if (body.messages[i].role === 'user') { lastUserIdx = i; break }
13
+ }
14
+ if (lastUserIdx === -1) return targets
15
+
16
+ const msg = body.messages[lastUserIdx]
17
+
18
+ if (typeof msg.content === 'string') {
19
+ targets.push({ path: ['messages', lastUserIdx, 'content'], text: msg.content })
20
+ } else if (Array.isArray(msg.content)) {
21
+ for (let i = 0; i < msg.content.length; i++) {
22
+ const block = msg.content[i]
23
+ if (block.type !== 'tool_result') continue
24
+ if (block.is_error) { targets.push({ skip: 'error', index: i }); continue }
25
+
26
+ if (typeof block.content === 'string') {
27
+ targets.push({ path: ['messages', lastUserIdx, 'content', i, 'content'], text: block.content, index: i })
28
+ } else if (Array.isArray(block.content)) {
29
+ for (let j = 0; j < block.content.length; j++) {
30
+ const sub = block.content[j]
31
+ if (sub.type === 'text') {
32
+ targets.push({ path: ['messages', lastUserIdx, 'content', i, 'content', j, 'text'], text: sub.text, index: i })
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return targets
39
+ },
40
+ apply(body, targets) {
41
+ for (const t of targets) {
42
+ if (t.skip || !t.compressed) continue
43
+ let obj = body
44
+ const path = t.path
45
+ for (let i = 0; i < path.length - 1; i++) obj = obj[path[i]]
46
+ obj[path[path.length - 1]] = t.compressed
47
+ }
48
+ },
49
+ }
50
+
51
+ const openai = {
52
+ name: 'openai',
53
+ match(method, url) {
54
+ return method === 'POST' && (url.startsWith('/v1/chat/completions') || url.startsWith('/chat/completions'))
55
+ },
56
+ normalizeUrl(url) {
57
+ if (url.startsWith('/chat/completions')) return '/v1' + url
58
+ return url
59
+ },
60
+ extract(body) {
61
+ const targets = []
62
+ if (!body?.messages?.length) return targets
63
+
64
+ // Find last assistant message with tool_calls
65
+ let lastAssistantIdx = -1
66
+ for (let i = body.messages.length - 1; i >= 0; i--) {
67
+ if (body.messages[i].role === 'assistant' && body.messages[i].tool_calls?.length) {
68
+ lastAssistantIdx = i
69
+ break
70
+ }
71
+ }
72
+ if (lastAssistantIdx === -1) return targets
73
+
74
+ // Collect all subsequent role:tool messages
75
+ for (let i = lastAssistantIdx + 1; i < body.messages.length; i++) {
76
+ const msg = body.messages[i]
77
+ if (msg.role !== 'tool') break
78
+ if (typeof msg.content === 'string') {
79
+ targets.push({ path: ['messages', i, 'content'], text: msg.content, index: i })
80
+ }
81
+ }
82
+ return targets
83
+ },
84
+ apply(body, targets) {
85
+ for (const t of targets) {
86
+ if (t.skip || !t.compressed) continue
87
+ let obj = body
88
+ const path = t.path
89
+ for (let i = 0; i < path.length - 1; i++) obj = obj[path[i]]
90
+ obj[path[path.length - 1]] = t.compressed
91
+ }
92
+ },
93
+ }
94
+
95
+ const gemini = {
96
+ name: 'gemini',
97
+ match(method, url) {
98
+ return method === 'POST' && url.includes('generateContent')
99
+ },
100
+ extract(body) {
101
+ const targets = []
102
+ if (!body?.contents?.length) return targets
103
+
104
+ // Find last content with functionResponse parts
105
+ for (let ci = body.contents.length - 1; ci >= 0; ci--) {
106
+ const content = body.contents[ci]
107
+ if (!content.parts?.length) continue
108
+ for (let pi = 0; pi < content.parts.length; pi++) {
109
+ const part = content.parts[pi]
110
+ if (!part.functionResponse?.response) continue
111
+ const resp = part.functionResponse.response
112
+ const text = typeof resp === 'string' ? resp : JSON.stringify(resp, null, 2)
113
+ targets.push({
114
+ path: ['contents', ci, 'parts', pi, 'functionResponse', 'response'],
115
+ text,
116
+ index: pi,
117
+ wasObject: typeof resp !== 'string',
118
+ })
119
+ }
120
+ if (targets.length) break
121
+ }
122
+ return targets
123
+ },
124
+ apply(body, targets) {
125
+ for (const t of targets) {
126
+ if (t.skip || !t.compressed) continue
127
+ let obj = body
128
+ const path = t.path
129
+ for (let i = 0; i < path.length - 1; i++) obj = obj[path[i]]
130
+ // If original was object, try to parse compressed back to object
131
+ if (t.wasObject) {
132
+ try {
133
+ obj[path[path.length - 1]] = JSON.parse(t.compressed)
134
+ continue
135
+ } catch { /* fall through to string */ }
136
+ }
137
+ obj[path[path.length - 1]] = t.compressed
138
+ }
139
+ },
140
+ }
141
+
142
+ const providers = [anthropic, openai, gemini]
143
+
144
+ export function detectProvider(method, url) {
145
+ for (const p of providers) {
146
+ if (p.match(method, url)) return p
147
+ }
148
+ return null
149
+ }
150
+
151
+ export { anthropic, openai, gemini }
package/stats.js CHANGED
@@ -1,7 +1,9 @@
1
- export function formatRequestLog(stats, session) {
1
+ export function formatRequestLog(stats, session, providerName, url) {
2
2
  const compressed = stats.filter(s => s.method)
3
3
  const skipped = stats.filter(s => s.skipped)
4
- const lines = [`[toona] POST /v1/messages — ${stats.length} blocks, ${compressed.length} compressed`]
4
+ const label = providerName || 'anthropic'
5
+ const path = url || '/v1/messages'
6
+ const lines = [`[toona] ${label} ${path} — ${stats.length} blocks, ${compressed.length} compressed`]
5
7
 
6
8
  for (const s of stats) {
7
9
  if (s.skipped) {