@sliday/tamp 0.1.1 → 0.2.0

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,16 @@ 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 upstreamUrl = new URL(req.url, upstream)
98
+
91
99
  const chunks = []
92
100
  let size = 0
93
101
  let overflow = false
@@ -113,12 +121,12 @@ return http.createServer(async (req, res) => {
113
121
 
114
122
  try {
115
123
  const parsed = JSON.parse(rawBody.toString('utf-8'))
116
- const { body, stats } = await compressMessages(parsed, config)
124
+ const { body, stats } = await compressRequest(parsed, config, provider)
117
125
  finalBody = Buffer.from(JSON.stringify(body), 'utf-8')
118
126
 
119
127
  if (config.log && stats.length) {
120
128
  session.record(stats)
121
- console.error(formatRequestLog(stats, session))
129
+ console.error(formatRequestLog(stats, session, provider.name, req.url))
122
130
  }
123
131
  } catch (err) {
124
132
  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.0",
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,147 @@
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')
55
+ },
56
+ extract(body) {
57
+ const targets = []
58
+ if (!body?.messages?.length) return targets
59
+
60
+ // Find last assistant message with tool_calls
61
+ let lastAssistantIdx = -1
62
+ for (let i = body.messages.length - 1; i >= 0; i--) {
63
+ if (body.messages[i].role === 'assistant' && body.messages[i].tool_calls?.length) {
64
+ lastAssistantIdx = i
65
+ break
66
+ }
67
+ }
68
+ if (lastAssistantIdx === -1) return targets
69
+
70
+ // Collect all subsequent role:tool messages
71
+ for (let i = lastAssistantIdx + 1; i < body.messages.length; i++) {
72
+ const msg = body.messages[i]
73
+ if (msg.role !== 'tool') break
74
+ if (typeof msg.content === 'string') {
75
+ targets.push({ path: ['messages', i, 'content'], text: msg.content, index: i })
76
+ }
77
+ }
78
+ return targets
79
+ },
80
+ apply(body, targets) {
81
+ for (const t of targets) {
82
+ if (t.skip || !t.compressed) continue
83
+ let obj = body
84
+ const path = t.path
85
+ for (let i = 0; i < path.length - 1; i++) obj = obj[path[i]]
86
+ obj[path[path.length - 1]] = t.compressed
87
+ }
88
+ },
89
+ }
90
+
91
+ const gemini = {
92
+ name: 'gemini',
93
+ match(method, url) {
94
+ return method === 'POST' && url.includes('generateContent')
95
+ },
96
+ extract(body) {
97
+ const targets = []
98
+ if (!body?.contents?.length) return targets
99
+
100
+ // Find last content with functionResponse parts
101
+ for (let ci = body.contents.length - 1; ci >= 0; ci--) {
102
+ const content = body.contents[ci]
103
+ if (!content.parts?.length) continue
104
+ for (let pi = 0; pi < content.parts.length; pi++) {
105
+ const part = content.parts[pi]
106
+ if (!part.functionResponse?.response) continue
107
+ const resp = part.functionResponse.response
108
+ const text = typeof resp === 'string' ? resp : JSON.stringify(resp, null, 2)
109
+ targets.push({
110
+ path: ['contents', ci, 'parts', pi, 'functionResponse', 'response'],
111
+ text,
112
+ index: pi,
113
+ wasObject: typeof resp !== 'string',
114
+ })
115
+ }
116
+ if (targets.length) break
117
+ }
118
+ return targets
119
+ },
120
+ apply(body, targets) {
121
+ for (const t of targets) {
122
+ if (t.skip || !t.compressed) continue
123
+ let obj = body
124
+ const path = t.path
125
+ for (let i = 0; i < path.length - 1; i++) obj = obj[path[i]]
126
+ // If original was object, try to parse compressed back to object
127
+ if (t.wasObject) {
128
+ try {
129
+ obj[path[path.length - 1]] = JSON.parse(t.compressed)
130
+ continue
131
+ } catch { /* fall through to string */ }
132
+ }
133
+ obj[path[path.length - 1]] = t.compressed
134
+ }
135
+ },
136
+ }
137
+
138
+ const providers = [anthropic, openai, gemini]
139
+
140
+ export function detectProvider(method, url) {
141
+ for (const p of providers) {
142
+ if (p.match(method, url)) return p
143
+ }
144
+ return null
145
+ }
146
+
147
+ 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) {