chopratejas commited on
Commit
facf3b5
·
1 Parent(s): f166566

Enhanced MCP server with compress/stats tools, fix proxy torch crash

Browse files

- Fix proxy crash when torch not installed: make kompress_compressor.py
imports lazy so `is_kompress_available()` works without [ml] extra
- Rewrite MCP server from 1 tool (retrieve-only) to 3 tools:
headroom_compress (on-demand compression, no proxy needed),
headroom_retrieve (local store first, proxy fallback),
headroom_stats (session stats + sub-agent aggregation + proxy cache)
- Add shared stats file (~/.headroom/session_stats.jsonl) so sub-agent
compression stats are visible from the main session
- Add mcp to [proxy] extras so proxy users get MCP tools automatically
- Remove dead TextCompressor from exports and pipeline (was never called)
- Update mcp install messaging to clarify proxy vs MCP roles
- Fix fcntl Windows compat, asyncio deprecation, httpx timeout race

Bump version to 0.4.6.

headroom/__init__.py CHANGED
@@ -153,7 +153,7 @@ from .transforms import (
153
  TransformPipeline,
154
  )
155
 
156
- __version__ = "0.4.5"
157
 
158
  __all__ = [
159
  # Main client
 
153
  TransformPipeline,
154
  )
155
 
156
+ __version__ = "0.4.6"
157
 
158
  __all__ = [
159
  # Main client
headroom/ccr/__init__.py CHANGED
@@ -60,11 +60,11 @@ from .tool_injection import (
60
 
61
  # MCP server is optional (requires mcp package)
62
  try:
63
- from .mcp_server import CCRMCPServer, create_ccr_mcp_server
64
 
65
  MCP_SERVER_AVAILABLE = True
66
  except ImportError:
67
- CCRMCPServer = None # type: ignore
68
  create_ccr_mcp_server = None # type: ignore
69
  MCP_SERVER_AVAILABLE = False
70
 
@@ -100,7 +100,7 @@ __all__ = [
100
  "process_batch_results",
101
  "reset_batch_context_store",
102
  # MCP server
103
- "CCRMCPServer",
104
  "create_ccr_mcp_server",
105
  "MCP_SERVER_AVAILABLE",
106
  ]
 
60
 
61
  # MCP server is optional (requires mcp package)
62
  try:
63
+ from .mcp_server import HeadroomMCPServer, create_ccr_mcp_server
64
 
65
  MCP_SERVER_AVAILABLE = True
66
  except ImportError:
67
+ HeadroomMCPServer = None # type: ignore
68
  create_ccr_mcp_server = None # type: ignore
69
  MCP_SERVER_AVAILABLE = False
70
 
 
100
  "process_batch_results",
101
  "reset_batch_context_store",
102
  # MCP server
103
+ "HeadroomMCPServer",
104
  "create_ccr_mcp_server",
105
  "MCP_SERVER_AVAILABLE",
106
  ]
headroom/ccr/mcp_server.py CHANGED
@@ -1,28 +1,23 @@
1
- """CCR MCP Server - Exposes headroom_retrieve as an MCP tool.
2
 
3
- This MCP server allows LLMs to retrieve compressed content via MCP instead
4
- of through injected tool definitions. It connects to the Headroom proxy's
5
- CompressionStore to serve retrieval requests.
 
 
 
 
6
 
7
  Usage:
8
- # As standalone server (stdio transport)
9
- python -m headroom.ccr.mcp_server
10
-
11
- # With custom proxy URL
12
- python -m headroom.ccr.mcp_server --proxy-url http://localhost:8787
13
-
14
- # Add to Claude Code's MCP config (~/.claude/mcp.json):
15
- {
16
- "mcpServers": {
17
- "headroom": {
18
- "command": "python",
19
- "args": ["-m", "headroom.ccr.mcp_server"]
20
- }
21
- }
22
- }
23
 
24
- When MCP is configured, the proxy will detect the tool is already present
25
- and skip tool injection, avoiding duplicate tools.
 
 
 
 
26
  """
27
 
28
  from __future__ import annotations
@@ -32,8 +27,19 @@ import asyncio
32
  import json
33
  import logging
34
  import os
 
 
 
35
  from typing import Any
36
 
 
 
 
 
 
 
 
 
37
  # Try to import MCP SDK
38
  try:
39
  from mcp.server import Server
@@ -55,125 +61,409 @@ except ImportError:
55
  HTTPX_AVAILABLE = False
56
  httpx = None # type: ignore[assignment]
57
 
58
- # Defined inline to avoid importing the full headroom package (which loads LiteLLM
59
- # and makes HTTP requests to GitHub, adding 4-5 seconds to startup time).
60
  CCR_TOOL_NAME = "headroom_retrieve"
 
 
61
 
62
  logger = logging.getLogger("headroom.ccr.mcp")
63
 
64
- # Default proxy URL (can be overridden via env or args)
65
  DEFAULT_PROXY_URL = os.environ.get("HEADROOM_PROXY_URL", "http://127.0.0.1:8787")
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- class CCRMCPServer:
69
- """MCP Server that exposes headroom_retrieve tool.
70
 
71
- This server can operate in two modes:
72
- 1. HTTP mode: Calls the proxy's /v1/retrieve endpoint (default)
73
- 2. Direct mode: Uses CompressionStore directly (same process)
 
 
 
74
 
75
- HTTP mode is recommended as it ensures consistency with the proxy.
 
 
 
76
  """
77
 
78
  def __init__(
79
  self,
80
  proxy_url: str = DEFAULT_PROXY_URL,
81
- direct_mode: bool = False,
82
  ):
83
- """Initialize CCR MCP Server.
84
-
85
- Args:
86
- proxy_url: URL of the Headroom proxy server.
87
- direct_mode: If True, access CompressionStore directly instead of via HTTP.
88
- """
89
  self.proxy_url = proxy_url
90
- self.direct_mode = direct_mode
91
- self._http_client: httpx.AsyncClient | None = None
 
 
 
92
 
93
  if not MCP_AVAILABLE:
94
  raise ImportError("MCP SDK not installed. Install with: pip install mcp")
95
 
96
- if not direct_mode and not HTTPX_AVAILABLE:
97
- raise ImportError(
98
- "httpx not installed (required for HTTP mode). Install with: pip install httpx"
 
 
 
 
 
 
 
 
99
  )
 
100
 
101
- self.server = Server("headroom-ccr")
102
- self._setup_handlers()
103
 
104
- def _setup_handlers(self):
105
- """Set up MCP tool handlers."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
  @self.server.list_tools()
108
  async def list_tools() -> list[Tool]:
109
- """Return available tools."""
110
  return [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  Tool(
112
  name=CCR_TOOL_NAME,
113
  description=(
114
- "Retrieve original uncompressed content that was compressed "
115
- "to save tokens. Use this when you need more data than what's "
116
- "shown in compressed tool results. The hash is provided in "
117
- "compression markers like [N items compressed... hash=abc123]."
118
  ),
119
  inputSchema={
120
  "type": "object",
121
  "properties": {
122
  "hash": {
123
  "type": "string",
124
- "description": "Hash key from the compression marker (e.g., 'abc123' from hash=abc123)",
125
  },
126
  "query": {
127
  "type": "string",
128
  "description": (
129
  "Optional search query to filter results. "
130
- "If provided, only returns items matching the query. "
131
- "If omitted, returns all original items."
132
  ),
133
  },
134
  },
135
  "required": ["hash"],
136
  },
137
- )
 
 
 
 
 
 
 
 
 
 
 
 
138
  ]
139
 
140
  @self.server.call_tool()
141
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
142
- """Handle tool calls."""
143
- if name != CCR_TOOL_NAME:
144
- return [
145
- TextContent(
146
- type="text",
147
- text=json.dumps({"error": f"Unknown tool: {name}"}),
148
- )
149
- ]
150
-
151
- hash_key = arguments.get("hash")
152
- query = arguments.get("query")
153
-
154
- if not hash_key:
155
- return [
156
- TextContent(
157
- type="text",
158
- text=json.dumps({"error": "hash parameter is required"}),
159
- )
160
- ]
161
-
162
- # Retrieve content
163
  try:
164
- if self.direct_mode:
165
- result = await self._retrieve_direct(hash_key, query)
 
 
 
 
166
  else:
167
- result = await self._retrieve_via_proxy(hash_key, query)
168
-
169
- return [
170
- TextContent(
171
- type="text",
172
- text=json.dumps(result, indent=2),
173
- )
174
- ]
175
  except Exception as e:
176
- logger.error(f"Retrieval failed: {e}")
177
  return [
178
  TextContent(
179
  type="text",
@@ -181,76 +471,127 @@ class CCRMCPServer:
181
  )
182
  ]
183
 
184
- async def _retrieve_via_proxy(
185
- self,
186
- hash_key: str,
187
- query: str | None,
188
- ) -> dict[str, Any]:
189
- """Retrieve content via proxy's HTTP endpoint."""
190
- if self._http_client is None:
191
- self._http_client = httpx.AsyncClient(timeout=30.0)
 
 
192
 
193
- url = f"{self.proxy_url}/v1/retrieve"
194
- payload = {"hash": hash_key}
195
- if query:
196
- payload["query"] = query
197
 
198
- response = await self._http_client.post(url, json=payload)
199
 
200
- if response.status_code == 404:
201
- return {
202
- "error": "Entry not found or expired (TTL: 5 minutes)",
203
- "hash": hash_key,
204
- }
 
 
 
 
 
205
 
206
- response.raise_for_status()
207
- result: dict[str, Any] = response.json()
208
- return result
209
 
210
- async def _retrieve_direct(
211
- self,
212
- hash_key: str,
213
- query: str | None,
214
- ) -> dict[str, Any]:
215
- """Retrieve content directly from CompressionStore."""
216
- from headroom.cache.compression_store import get_compression_store
217
 
218
- store = get_compression_store()
 
 
219
 
220
- if query:
221
- results = store.search(hash_key, query)
222
- return {
223
- "hash": hash_key,
224
- "query": query,
225
- "results": results,
226
- "count": len(results),
227
  }
228
- else:
229
- entry = store.retrieve(hash_key)
230
- if entry:
231
- return {
232
- "hash": hash_key,
233
- "original_content": entry.original_content,
234
- "original_item_count": entry.original_item_count,
235
- "compressed_item_count": entry.compressed_item_count,
236
- "retrieval_count": entry.retrieval_count,
237
- }
238
- return {
239
- "error": "Entry not found or expired (TTL: 5 minutes)",
240
- "hash": hash_key,
 
 
 
 
 
 
 
 
 
 
 
 
241
  }
242
 
243
- async def run_stdio(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  """Run the server with stdio transport."""
245
  async with stdio_server() as (read_stream, write_stream):
246
- logger.info(f"CCR MCP Server starting (proxy: {self.proxy_url})")
247
  await self.server.run(
248
  read_stream,
249
  write_stream,
250
  self.server.create_initialization_options(),
251
  )
252
 
253
- async def cleanup(self):
254
  """Clean up resources."""
255
  if self._http_client:
256
  await self._http_client.aclose()
@@ -259,39 +600,33 @@ class CCRMCPServer:
259
  def create_ccr_mcp_server(
260
  proxy_url: str = DEFAULT_PROXY_URL,
261
  direct_mode: bool = False,
262
- ) -> CCRMCPServer:
263
- """Create a CCR MCP server instance.
264
 
265
  Args:
266
- proxy_url: URL of the Headroom proxy server.
267
- direct_mode: If True, access CompressionStore directly.
268
 
269
  Returns:
270
- CCRMCPServer instance.
271
-
272
- Example:
273
- ```python
274
- server = create_ccr_mcp_server()
275
- await server.run_stdio()
276
- ```
277
  """
278
- return CCRMCPServer(proxy_url=proxy_url, direct_mode=direct_mode)
279
 
280
 
281
- async def main():
282
- """Run the CCR MCP server."""
283
  parser = argparse.ArgumentParser(
284
- description="CCR MCP Server - Retrieve compressed content via MCP"
285
  )
286
  parser.add_argument(
287
  "--proxy-url",
288
  default=DEFAULT_PROXY_URL,
289
- help=f"Headroom proxy URL (default: {DEFAULT_PROXY_URL})",
290
  )
291
  parser.add_argument(
292
  "--direct",
293
  action="store_true",
294
- help="Use direct CompressionStore access instead of HTTP",
295
  )
296
  parser.add_argument(
297
  "--debug",
@@ -304,12 +639,9 @@ async def main():
304
  if args.debug:
305
  logging.basicConfig(level=logging.DEBUG)
306
  else:
307
- logging.basicConfig(level=logging.INFO)
308
 
309
- server = create_ccr_mcp_server(
310
- proxy_url=args.proxy_url,
311
- direct_mode=args.direct,
312
- )
313
 
314
  try:
315
  await server.run_stdio()
 
1
+ """Headroom MCP Server Context engineering toolkit for AI coding tools.
2
 
3
+ Exposes Headroom's compression, retrieval, and observability as MCP tools
4
+ that any MCP-compatible host (Claude Code, Cursor, Codex, etc.) can use.
5
+
6
+ Tools:
7
+ headroom_compress — Compress content on demand (no proxy needed)
8
+ headroom_retrieve — Retrieve original uncompressed content by hash
9
+ headroom_stats — Session compression statistics
10
 
11
  Usage:
12
+ # As standalone server (stdio transport, called by AI coding tools)
13
+ headroom mcp serve
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
+ # Add to Claude Code
16
+ headroom mcp install
17
+
18
+ When running standalone (no proxy), compression and retrieval happen locally
19
+ in this process. When a proxy is running, retrieval can also fetch from the
20
+ proxy's compression store.
21
  """
22
 
23
  from __future__ import annotations
 
27
  import json
28
  import logging
29
  import os
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
  from typing import Any
34
 
35
+ # fcntl is Unix-only; on Windows we skip file locking (stats are best-effort)
36
+ try:
37
+ import fcntl
38
+
39
+ _HAS_FCNTL = True
40
+ except ImportError:
41
+ _HAS_FCNTL = False
42
+
43
  # Try to import MCP SDK
44
  try:
45
  from mcp.server import Server
 
61
  HTTPX_AVAILABLE = False
62
  httpx = None # type: ignore[assignment]
63
 
 
 
64
  CCR_TOOL_NAME = "headroom_retrieve"
65
+ COMPRESS_TOOL_NAME = "headroom_compress"
66
+ STATS_TOOL_NAME = "headroom_stats"
67
 
68
  logger = logging.getLogger("headroom.ccr.mcp")
69
 
 
70
  DEFAULT_PROXY_URL = os.environ.get("HEADROOM_PROXY_URL", "http://127.0.0.1:8787")
71
 
72
+ # Session-scoped TTL: content persists for the session (1 hour), not 5 minutes.
73
+ # The MCP server process lives as long as the coding session.
74
+ MCP_SESSION_TTL = 3600
75
+
76
+ # Shared stats file: all MCP instances (main + sub-agents) append here.
77
+ # headroom_stats aggregates across all instances within the session window.
78
+ SHARED_STATS_DIR = Path.home() / ".headroom"
79
+ SHARED_STATS_FILE = SHARED_STATS_DIR / "session_stats.jsonl"
80
+ SESSION_WINDOW_SECONDS = 7200 # 2 hours — events older than this are pruned
81
+
82
+
83
+ def _append_shared_event(event: dict[str, Any]) -> None:
84
+ """Append an event to the shared stats file (cross-process, file-locked)."""
85
+ try:
86
+ SHARED_STATS_DIR.mkdir(parents=True, exist_ok=True)
87
+ event["pid"] = os.getpid()
88
+ line = json.dumps(event, separators=(",", ":")) + "\n"
89
+ with open(SHARED_STATS_FILE, "a") as f:
90
+ if _HAS_FCNTL:
91
+ fcntl.flock(f, fcntl.LOCK_EX)
92
+ f.write(line)
93
+ if _HAS_FCNTL:
94
+ fcntl.flock(f, fcntl.LOCK_UN)
95
+ except Exception:
96
+ pass # Never break compression because of stats
97
+
98
+
99
+ def _read_shared_events(window_seconds: int = SESSION_WINDOW_SECONDS) -> list[dict[str, Any]]:
100
+ """Read shared events within the session time window, pruning old entries."""
101
+ if not SHARED_STATS_FILE.exists():
102
+ return []
103
+ cutoff = time.time() - window_seconds
104
+ events: list[dict[str, Any]] = []
105
+ keep_lines: list[str] = []
106
+ try:
107
+ with open(SHARED_STATS_FILE) as f:
108
+ if _HAS_FCNTL:
109
+ fcntl.flock(f, fcntl.LOCK_SH)
110
+ lines = f.readlines()
111
+ if _HAS_FCNTL:
112
+ fcntl.flock(f, fcntl.LOCK_UN)
113
+ for line in lines:
114
+ line = line.strip()
115
+ if not line:
116
+ continue
117
+ try:
118
+ evt = json.loads(line)
119
+ if evt.get("timestamp", 0) >= cutoff:
120
+ events.append(evt)
121
+ keep_lines.append(line + "\n")
122
+ except json.JSONDecodeError:
123
+ continue
124
+ # Prune old entries (only if we dropped some)
125
+ if len(keep_lines) < len(lines):
126
+ try:
127
+ with open(SHARED_STATS_FILE, "w") as f:
128
+ if _HAS_FCNTL:
129
+ fcntl.flock(f, fcntl.LOCK_EX)
130
+ f.writelines(keep_lines)
131
+ if _HAS_FCNTL:
132
+ fcntl.flock(f, fcntl.LOCK_UN)
133
+ except Exception:
134
+ pass
135
+ except Exception:
136
+ pass
137
+ return events
138
+
139
+
140
+ @dataclass
141
+ class SessionStats:
142
+ """Track compression statistics for the current MCP session."""
143
+
144
+ compressions: int = 0
145
+ retrievals: int = 0
146
+ total_input_tokens: int = 0
147
+ total_output_tokens: int = 0
148
+ total_tokens_saved: int = 0
149
+ started_at: float = field(default_factory=time.time)
150
+ events: list[dict[str, Any]] = field(default_factory=list)
151
+
152
+ def record_compression(
153
+ self,
154
+ input_tokens: int,
155
+ output_tokens: int,
156
+ strategy: str,
157
+ ) -> None:
158
+ self.compressions += 1
159
+ self.total_input_tokens += input_tokens
160
+ self.total_output_tokens += output_tokens
161
+ self.total_tokens_saved += max(0, input_tokens - output_tokens)
162
+ event = {
163
+ "type": "compress",
164
+ "input_tokens": input_tokens,
165
+ "output_tokens": output_tokens,
166
+ "savings_percent": round((1 - output_tokens / input_tokens) * 100, 1)
167
+ if input_tokens > 0
168
+ else 0,
169
+ "strategy": strategy,
170
+ "timestamp": time.time(),
171
+ }
172
+ self.events.append(event)
173
+ _append_shared_event(event)
174
+ # Keep last 50 events
175
+ if len(self.events) > 50:
176
+ self.events = self.events[-50:]
177
+
178
+ def record_retrieval(self, hash_key: str) -> None:
179
+ self.retrievals += 1
180
+ event = {
181
+ "type": "retrieve",
182
+ "hash": hash_key[:12],
183
+ "timestamp": time.time(),
184
+ }
185
+ self.events.append(event)
186
+ _append_shared_event(event)
187
+ if len(self.events) > 50:
188
+ self.events = self.events[-50:]
189
+
190
+ def to_dict(self) -> dict[str, Any]:
191
+ savings_pct = (
192
+ round((self.total_tokens_saved / self.total_input_tokens) * 100, 1)
193
+ if self.total_input_tokens > 0
194
+ else 0
195
+ )
196
+ # Rough cost estimate (blended rate ~$3/1M input tokens)
197
+ cost_saved = round(self.total_tokens_saved * 3.0 / 1_000_000, 4)
198
+
199
+ return {
200
+ "session_duration_seconds": round(time.time() - self.started_at),
201
+ "compressions": self.compressions,
202
+ "retrievals": self.retrievals,
203
+ "total_input_tokens": self.total_input_tokens,
204
+ "total_output_tokens": self.total_output_tokens,
205
+ "total_tokens_saved": self.total_tokens_saved,
206
+ "savings_percent": savings_pct,
207
+ "estimated_cost_saved_usd": cost_saved,
208
+ "recent_events": self.events[-10:],
209
+ }
210
+
211
 
212
+ class HeadroomMCPServer:
213
+ """MCP Server exposing Headroom's context engineering toolkit.
214
 
215
+ Tools:
216
+ headroom_compress Compress content on demand. Stores original for
217
+ retrieval. Works without a proxy.
218
+ headroom_retrieve — Retrieve original uncompressed content by hash.
219
+ Checks local store first, then proxy if configured.
220
+ headroom_stats — Session statistics: compressions, savings, cost.
221
 
222
+ Modes:
223
+ Standalone: Compression + retrieval happen locally. No proxy needed.
224
+ With proxy: Retrieval also checks the proxy's compression store
225
+ (for content compressed by the proxy's automatic pipeline).
226
  """
227
 
228
  def __init__(
229
  self,
230
  proxy_url: str = DEFAULT_PROXY_URL,
231
+ check_proxy: bool = True,
232
  ):
 
 
 
 
 
 
233
  self.proxy_url = proxy_url
234
+ self.check_proxy = check_proxy
235
+ self._http_client: httpx.AsyncClient | None = None # type: ignore[assignment]
236
+ self._stats = SessionStats()
237
+ self._local_store: Any = None # Lazy-initialized CompressionStore
238
+ self._compressor_initialized = False
239
 
240
  if not MCP_AVAILABLE:
241
  raise ImportError("MCP SDK not installed. Install with: pip install mcp")
242
 
243
+ self.server = Server("headroom")
244
+ self._setup_handlers()
245
+
246
+ def _get_local_store(self) -> Any:
247
+ """Get or create the local compression store (lazy init)."""
248
+ if self._local_store is None:
249
+ from headroom.cache.compression_store import CompressionStore
250
+
251
+ self._local_store = CompressionStore(
252
+ max_entries=500,
253
+ default_ttl=MCP_SESSION_TTL,
254
  )
255
+ return self._local_store
256
 
257
+ def _compress_content(self, content: str) -> dict[str, Any]:
258
+ """Compress content using Headroom's pipeline.
259
 
260
+ Returns dict with compressed text, token counts, hash, etc.
261
+ """
262
+ from headroom.compress import compress
263
+
264
+ # Wrap content as a tool message (most common compression target)
265
+ messages = [{"role": "tool", "content": content}]
266
+
267
+ result = compress(messages, model="claude-sonnet-4-5-20250929")
268
+
269
+ compressed_content = result.messages[0].get("content", content)
270
+ input_tokens = result.tokens_before
271
+ output_tokens = result.tokens_after
272
+
273
+ # Store original in local store for later retrieval
274
+ store = self._get_local_store()
275
+ hash_key = store.store(
276
+ original=content,
277
+ compressed=compressed_content
278
+ if isinstance(compressed_content, str)
279
+ else json.dumps(compressed_content),
280
+ original_tokens=input_tokens,
281
+ compressed_tokens=output_tokens,
282
+ compression_strategy="mcp_compress",
283
+ ttl=MCP_SESSION_TTL,
284
+ )
285
+
286
+ # Track stats
287
+ strategy = (
288
+ ", ".join(result.transforms_applied) if result.transforms_applied else "passthrough"
289
+ )
290
+ self._stats.record_compression(input_tokens, output_tokens, strategy)
291
+
292
+ savings_pct = (
293
+ round((1 - result.compression_ratio) * 100, 1) if result.compression_ratio < 1.0 else 0
294
+ )
295
+
296
+ return {
297
+ "compressed": compressed_content,
298
+ "hash": hash_key,
299
+ "original_tokens": input_tokens,
300
+ "compressed_tokens": output_tokens,
301
+ "tokens_saved": max(0, input_tokens - output_tokens),
302
+ "savings_percent": savings_pct,
303
+ "transforms": result.transforms_applied,
304
+ "note": f"Original stored with hash={hash_key}. Use headroom_retrieve to get full content later.",
305
+ }
306
+
307
+ async def _retrieve_content(
308
+ self,
309
+ hash_key: str,
310
+ query: str | None,
311
+ ) -> dict[str, Any]:
312
+ """Retrieve content. Checks local store first, then proxy."""
313
+ # Check local store first
314
+ store = self._get_local_store()
315
+ if query:
316
+ results = store.search(hash_key, query)
317
+ if results:
318
+ self._stats.record_retrieval(hash_key)
319
+ return {
320
+ "hash": hash_key,
321
+ "source": "local",
322
+ "query": query,
323
+ "results": results,
324
+ "count": len(results),
325
+ }
326
+ else:
327
+ entry = store.retrieve(hash_key)
328
+ if entry:
329
+ self._stats.record_retrieval(hash_key)
330
+ return {
331
+ "hash": hash_key,
332
+ "source": "local",
333
+ "original_content": entry.original_content,
334
+ "original_item_count": entry.original_item_count,
335
+ "compressed_item_count": entry.compressed_item_count,
336
+ "retrieval_count": entry.retrieval_count,
337
+ }
338
+
339
+ # Fall back to proxy if available
340
+ if self.check_proxy and HTTPX_AVAILABLE:
341
+ try:
342
+ result = await self._retrieve_via_proxy(hash_key, query)
343
+ if "error" not in result:
344
+ result["source"] = "proxy"
345
+ self._stats.record_retrieval(hash_key)
346
+ return result
347
+ except Exception:
348
+ pass # Proxy unavailable, that's fine
349
+
350
+ return {
351
+ "error": "Content not found. It may have expired or the hash may be incorrect.",
352
+ "hash": hash_key,
353
+ "hint": "Content compressed via headroom_compress is stored for the session. "
354
+ "Content compressed by the proxy has a shorter TTL (5 minutes).",
355
+ }
356
+
357
+ async def _retrieve_via_proxy(
358
+ self,
359
+ hash_key: str,
360
+ query: str | None,
361
+ ) -> dict[str, Any]:
362
+ """Retrieve content via proxy's HTTP endpoint."""
363
+ if self._http_client is None:
364
+ self._http_client = httpx.AsyncClient(timeout=15.0)
365
+
366
+ url = f"{self.proxy_url}/v1/retrieve"
367
+ payload: dict[str, str] = {"hash": hash_key}
368
+ if query:
369
+ payload["query"] = query
370
+
371
+ response = await self._http_client.post(url, json=payload)
372
+
373
+ if response.status_code == 404:
374
+ return {"error": "Not found in proxy store", "hash": hash_key}
375
+
376
+ response.raise_for_status()
377
+ result: dict[str, Any] = response.json()
378
+ return result
379
+
380
+ def _setup_handlers(self) -> None:
381
+ """Register all MCP tool handlers."""
382
 
383
  @self.server.list_tools()
384
  async def list_tools() -> list[Tool]:
 
385
  return [
386
+ Tool(
387
+ name=COMPRESS_TOOL_NAME,
388
+ description=(
389
+ "Compress content to save context window space. "
390
+ "Use this on large tool outputs, file contents, search results, "
391
+ "or any content you want to shrink before reasoning over it. "
392
+ "The original is stored and can be retrieved later via headroom_retrieve. "
393
+ "Returns compressed text + a hash for retrieval."
394
+ ),
395
+ inputSchema={
396
+ "type": "object",
397
+ "properties": {
398
+ "content": {
399
+ "type": "string",
400
+ "description": (
401
+ "The content to compress. Can be any text: file contents, "
402
+ "JSON, search results, logs, code, etc."
403
+ ),
404
+ },
405
+ },
406
+ "required": ["content"],
407
+ },
408
+ ),
409
  Tool(
410
  name=CCR_TOOL_NAME,
411
  description=(
412
+ "Retrieve original uncompressed content by hash. "
413
+ "Use this when you need full details from previously compressed content. "
414
+ "The hash comes from headroom_compress results or from compression "
415
+ "markers like [N items compressed... hash=abc123]."
416
  ),
417
  inputSchema={
418
  "type": "object",
419
  "properties": {
420
  "hash": {
421
  "type": "string",
422
+ "description": "Hash key from compression (e.g., 'abc123' from hash=abc123)",
423
  },
424
  "query": {
425
  "type": "string",
426
  "description": (
427
  "Optional search query to filter results. "
428
+ "If provided, returns only items matching the query."
 
429
  ),
430
  },
431
  },
432
  "required": ["hash"],
433
  },
434
+ ),
435
+ Tool(
436
+ name=STATS_TOOL_NAME,
437
+ description=(
438
+ "Show compression statistics for this session: "
439
+ "total compressions, tokens saved, estimated cost savings, "
440
+ "and recent compression events."
441
+ ),
442
+ inputSchema={
443
+ "type": "object",
444
+ "properties": {},
445
+ },
446
+ ),
447
  ]
448
 
449
  @self.server.call_tool()
450
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  try:
452
+ if name == COMPRESS_TOOL_NAME:
453
+ return await self._handle_compress(arguments)
454
+ elif name == CCR_TOOL_NAME:
455
+ return await self._handle_retrieve(arguments)
456
+ elif name == STATS_TOOL_NAME:
457
+ return await self._handle_stats()
458
  else:
459
+ return [
460
+ TextContent(
461
+ type="text",
462
+ text=json.dumps({"error": f"Unknown tool: {name}"}),
463
+ )
464
+ ]
 
 
465
  except Exception as e:
466
+ logger.error(f"Tool {name} failed: {e}", exc_info=True)
467
  return [
468
  TextContent(
469
  type="text",
 
471
  )
472
  ]
473
 
474
+ async def _handle_compress(self, arguments: dict[str, Any]) -> list[TextContent]:
475
+ """Handle headroom_compress tool call."""
476
+ content = arguments.get("content")
477
+ if not content:
478
+ return [
479
+ TextContent(
480
+ type="text",
481
+ text=json.dumps({"error": "content parameter is required"}),
482
+ )
483
+ ]
484
 
485
+ # Run compression in thread pool (it's CPU-bound)
486
+ loop = asyncio.get_running_loop()
487
+ result = await loop.run_in_executor(None, self._compress_content, content)
 
488
 
489
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
490
 
491
+ async def _handle_retrieve(self, arguments: dict[str, Any]) -> list[TextContent]:
492
+ """Handle headroom_retrieve tool call."""
493
+ hash_key = arguments.get("hash")
494
+ if not hash_key:
495
+ return [
496
+ TextContent(
497
+ type="text",
498
+ text=json.dumps({"error": "hash parameter is required"}),
499
+ )
500
+ ]
501
 
502
+ query = arguments.get("query")
503
+ result = await self._retrieve_content(hash_key, query)
 
504
 
505
+ return [TextContent(type="text", text=json.dumps(result, indent=2))]
 
 
 
 
 
 
506
 
507
+ async def _handle_stats(self) -> list[TextContent]:
508
+ """Handle headroom_stats tool call."""
509
+ stats = self._stats.to_dict()
510
 
511
+ # Add local store stats if available
512
+ if self._local_store is not None:
513
+ store_stats = self._local_store.get_stats()
514
+ stats["store"] = {
515
+ "entries": store_stats.get("entry_count", 0),
516
+ "max_entries": store_stats.get("max_entries", 0),
 
517
  }
518
+
519
+ # Aggregate cross-process stats (main session + sub-agents)
520
+ my_pid = os.getpid()
521
+ shared_events = _read_shared_events()
522
+ other_events = [e for e in shared_events if e.get("pid") != my_pid]
523
+ if other_events:
524
+ other_compressions = [e for e in other_events if e.get("type") == "compress"]
525
+ other_input = sum(e.get("input_tokens", 0) for e in other_compressions)
526
+ other_output = sum(e.get("output_tokens", 0) for e in other_compressions)
527
+ other_saved = max(0, other_input - other_output)
528
+ stats["sub_agents"] = {
529
+ "compressions": len(other_compressions),
530
+ "retrievals": sum(1 for e in other_events if e.get("type") == "retrieve"),
531
+ "tokens_saved": other_saved,
532
+ "total_input_tokens": other_input,
533
+ "total_output_tokens": other_output,
534
+ }
535
+ # Combined totals
536
+ all_input = self._stats.total_input_tokens + other_input
537
+ all_saved = self._stats.total_tokens_saved + other_saved
538
+ stats["combined"] = {
539
+ "total_compressions": self._stats.compressions + len(other_compressions),
540
+ "total_tokens_saved": all_saved,
541
+ "savings_percent": round(all_saved / all_input * 100, 1) if all_input > 0 else 0,
542
+ "estimated_cost_saved_usd": round(all_saved * 3.0 / 1_000_000, 4),
543
  }
544
 
545
+ # Fetch proxy stats (prefix cache hits, etc.) if proxy is reachable
546
+ if self.check_proxy and HTTPX_AVAILABLE:
547
+ proxy_stats = await self._fetch_proxy_stats()
548
+ if proxy_stats:
549
+ stats["proxy"] = proxy_stats
550
+
551
+ return [TextContent(type="text", text=json.dumps(stats, indent=2))]
552
+
553
+ async def _fetch_proxy_stats(self) -> dict[str, Any] | None:
554
+ """Fetch stats from the proxy, including prefix cache hit info."""
555
+ try:
556
+ if self._http_client is None:
557
+ self._http_client = httpx.AsyncClient(timeout=15.0)
558
+ response = await self._http_client.get(f"{self.proxy_url}/stats")
559
+ if response.status_code != 200:
560
+ return None
561
+ data = response.json()
562
+ # Extract the most useful fields
563
+ result: dict[str, Any] = {}
564
+ if "requests_total" in data:
565
+ result["requests_total"] = data["requests_total"]
566
+ if "tokens_saved_total" in data:
567
+ result["tokens_saved_total"] = data["tokens_saved_total"]
568
+ # Prefix cache stats
569
+ cache = data.get("cache", data.get("caching", {}))
570
+ if cache:
571
+ result["cache"] = {
572
+ "hits": cache.get("hits", cache.get("cache_hits", 0)),
573
+ "misses": cache.get("misses", cache.get("cache_misses", 0)),
574
+ "hit_rate": cache.get("hit_rate", cache.get("cache_hit_rate", 0)),
575
+ }
576
+ # Cost tracking
577
+ cost = data.get("cost", {})
578
+ if cost:
579
+ result["cost_saved_usd"] = cost.get("total_saved", cost.get("saved", 0))
580
+ return result if result else None
581
+ except Exception:
582
+ return None
583
+
584
+ async def run_stdio(self) -> None:
585
  """Run the server with stdio transport."""
586
  async with stdio_server() as (read_stream, write_stream):
587
+ logger.info(f"Headroom MCP Server starting (proxy: {self.proxy_url})")
588
  await self.server.run(
589
  read_stream,
590
  write_stream,
591
  self.server.create_initialization_options(),
592
  )
593
 
594
+ async def cleanup(self) -> None:
595
  """Clean up resources."""
596
  if self._http_client:
597
  await self._http_client.aclose()
 
600
  def create_ccr_mcp_server(
601
  proxy_url: str = DEFAULT_PROXY_URL,
602
  direct_mode: bool = False,
603
+ ) -> HeadroomMCPServer:
604
+ """Create a Headroom MCP server instance.
605
 
606
  Args:
607
+ proxy_url: URL of the Headroom proxy server (for retrieval fallback).
608
+ direct_mode: Ignored (kept for backward compatibility).
609
 
610
  Returns:
611
+ HeadroomMCPServer instance.
 
 
 
 
 
 
612
  """
613
+ return HeadroomMCPServer(proxy_url=proxy_url)
614
 
615
 
616
+ async def main() -> None:
617
+ """Run the Headroom MCP server."""
618
  parser = argparse.ArgumentParser(
619
+ description="Headroom MCP Server Context engineering toolkit"
620
  )
621
  parser.add_argument(
622
  "--proxy-url",
623
  default=DEFAULT_PROXY_URL,
624
+ help=f"Headroom proxy URL for retrieval fallback (default: {DEFAULT_PROXY_URL})",
625
  )
626
  parser.add_argument(
627
  "--direct",
628
  action="store_true",
629
+ help="(Deprecated, ignored) Use direct CompressionStore access",
630
  )
631
  parser.add_argument(
632
  "--debug",
 
639
  if args.debug:
640
  logging.basicConfig(level=logging.DEBUG)
641
  else:
642
+ logging.basicConfig(level=logging.WARNING)
643
 
644
+ server = HeadroomMCPServer(proxy_url=args.proxy_url)
 
 
 
645
 
646
  try:
647
  await server.run_stdio()
headroom/ccr/tool_injection.py CHANGED
@@ -197,7 +197,7 @@ class CCRToolInjector:
197
  # Multiple marker patterns to match different compressors:
198
  # - SmartCrusher: [100 items compressed to 10. Retrieve more: hash=abc123]
199
  # - LLMLingua: [1000 items compressed to 300. Retrieve more: hash=abc123]
200
- # - TextCompressor: [100 lines compressed to 10. Retrieve more: hash=abc123]
201
  # - LogCompressor: [200 lines compressed to 20. Retrieve more: hash=abc123]
202
  # - SearchCompressor: [50 matches compressed to 5. Retrieve more: hash=abc123]
203
  # - Generic: any [... compressed ... hash=xxx] pattern
 
197
  # Multiple marker patterns to match different compressors:
198
  # - SmartCrusher: [100 items compressed to 10. Retrieve more: hash=abc123]
199
  # - LLMLingua: [1000 items compressed to 300. Retrieve more: hash=abc123]
200
+ # - Kompress: [100 lines compressed to 10. Retrieve more: hash=abc123]
201
  # - LogCompressor: [200 lines compressed to 20. Retrieve more: hash=abc123]
202
  # - SearchCompressor: [50 matches compressed to 5. Retrieve more: hash=abc123]
203
  # - Generic: any [... compressed ... hash=xxx] pattern
headroom/cli/mcp.py CHANGED
@@ -69,14 +69,20 @@ def mcp() -> None:
69
  Quick Start:
70
  headroom mcp install # Configure Claude Code
71
  headroom proxy # Start the proxy (in another terminal)
72
- claude # Start Claude Code - it now has headroom!
 
 
 
 
 
73
 
74
  \b
75
  How it works:
76
- 1. The proxy compresses large tool outputs (file listings, search results)
77
- 2. Claude sees compressed summaries with hash markers
78
- 3. When Claude needs full details, it calls headroom_retrieve
79
- 4. The MCP server fetches original content from the proxy
 
80
  """
81
  pass
82
 
@@ -190,14 +196,17 @@ Next steps:
190
  1. Start the Headroom proxy (if not running):
191
  headroom proxy
192
 
193
- 2. Start Claude Code:
194
- claude
195
 
196
- 3. Claude Code now has access to headroom_retrieve tool!
197
- Compressed content will show hash markers like:
198
- [47 items compressed... hash=abc123]
 
199
 
200
- Claude can retrieve full details when needed.
 
 
201
 
202
  Proxy URL: {proxy_url}
203
  """)
 
69
  Quick Start:
70
  headroom mcp install # Configure Claude Code
71
  headroom proxy # Start the proxy (in another terminal)
72
+ ANTHROPIC_BASE_URL=http://127.0.0.1:8787 claude
73
+
74
+ \b
75
+ The MCP server provides on-demand tools (compress, retrieve, stats).
76
+ For automatic compression of ALL traffic, also set ANTHROPIC_BASE_URL
77
+ to route through the proxy.
78
 
79
  \b
80
  How it works:
81
+ 1. ANTHROPIC_BASE_URL routes all requests through the proxy
82
+ 2. The proxy compresses large tool outputs (file listings, search results)
83
+ 3. Claude sees compressed summaries with hash markers
84
+ 4. When Claude needs full details, it calls headroom_retrieve
85
+ 5. The MCP server fetches original content from the proxy
86
  """
87
  pass
88
 
 
196
  1. Start the Headroom proxy (if not running):
197
  headroom proxy
198
 
199
+ 2. Start Claude Code WITH the proxy base URL:
200
+ ANTHROPIC_BASE_URL={proxy_url} claude
201
 
202
+ 3. Claude Code now has:
203
+ - All requests compressed through the proxy (saves tokens & cost)
204
+ - Access to headroom_retrieve tool for CCR retrieval
205
+ - Stats visible at {proxy_url}/stats
206
 
207
+ NOTE: The MCP server provides on-demand compression tools
208
+ (headroom_compress, headroom_retrieve, headroom_stats). For automatic
209
+ compression of ALL traffic, also set ANTHROPIC_BASE_URL as shown above.
210
 
211
  Proxy URL: {proxy_url}
212
  """)
headroom/transforms/__init__.py CHANGED
@@ -23,7 +23,6 @@ from .search_compressor import (
23
  SearchCompressorConfig,
24
  )
25
  from .smart_crusher import SmartCrusher, SmartCrusherConfig
26
- from .text_compressor import TextCompressionResult, TextCompressor, TextCompressorConfig
27
  from .tool_crusher import ToolCrusher
28
 
29
  # ML-based compression (optional dependency)
@@ -101,9 +100,6 @@ __all__ = [
101
  "DiffCompressor",
102
  "DiffCompressorConfig",
103
  "DiffCompressionResult",
104
- "TextCompressor",
105
- "TextCompressorConfig",
106
- "TextCompressionResult",
107
  # Code-aware compression (AST-based)
108
  "CodeAwareCompressor",
109
  "CodeCompressorConfig",
 
23
  SearchCompressorConfig,
24
  )
25
  from .smart_crusher import SmartCrusher, SmartCrusherConfig
 
26
  from .tool_crusher import ToolCrusher
27
 
28
  # ML-based compression (optional dependency)
 
100
  "DiffCompressor",
101
  "DiffCompressorConfig",
102
  "DiffCompressionResult",
 
 
 
103
  # Code-aware compression (AST-based)
104
  "CodeAwareCompressor",
105
  "CodeCompressorConfig",
headroom/transforms/content_router.py CHANGED
@@ -10,7 +10,7 @@ Supported Compressors:
10
  - SearchCompressor: grep/ripgrep results
11
  - LogCompressor: Build/test output
12
  - LLMLinguaCompressor: Plain text (ML-based)
13
- - TextCompressor: Plain text (heuristic-based)
14
 
15
  Routing Strategy:
16
  1. Use source hint if available (highest confidence)
@@ -637,7 +637,6 @@ class ContentRouter(Transform):
637
  self._html_extractor: Any = None
638
  self._kompress: Any = None
639
  self._llmlingua: Any = None
640
- self._text_compressor: Any = None
641
  self._image_optimizer: Any = None
642
 
643
  # TOIN integration for cross-strategy learning
@@ -1000,7 +999,7 @@ class ContentRouter(Transform):
1000
 
1001
  elif strategy == CompressionStrategy.TEXT:
1002
  # Prefer ML compressor (Kompress > LLMLingua) for text
1003
- # Falls back to heuristic TextCompressor if neither available
1004
  compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
1005
 
1006
  except Exception as e:
@@ -1221,17 +1220,6 @@ class ContentRouter(Transform):
1221
  logger.debug("LLMLinguaCompressor not available")
1222
  return self._llmlingua
1223
 
1224
- def _get_text_compressor(self) -> Any:
1225
- """Get TextCompressor (lazy load)."""
1226
- if self._text_compressor is None:
1227
- try:
1228
- from .text_compressor import TextCompressor
1229
-
1230
- self._text_compressor = TextCompressor()
1231
- except ImportError:
1232
- logger.debug("TextCompressor not available")
1233
- return self._text_compressor
1234
-
1235
  def _get_image_optimizer(self) -> Any:
1236
  """Get ImageCompressor (lazy load).
1237
 
 
10
  - SearchCompressor: grep/ripgrep results
11
  - LogCompressor: Build/test output
12
  - LLMLinguaCompressor: Plain text (ML-based)
13
+ - Kompress: Plain text (ML-based, requires [ml] extra)
14
 
15
  Routing Strategy:
16
  1. Use source hint if available (highest confidence)
 
637
  self._html_extractor: Any = None
638
  self._kompress: Any = None
639
  self._llmlingua: Any = None
 
640
  self._image_optimizer: Any = None
641
 
642
  # TOIN integration for cross-strategy learning
 
999
 
1000
  elif strategy == CompressionStrategy.TEXT:
1001
  # Prefer ML compressor (Kompress > LLMLingua) for text
1002
+ # Passes through unchanged if neither Kompress nor LLMLingua available
1003
  compressed, compressed_tokens = self._try_ml_compressor(content, context, question)
1004
 
1005
  except Exception as e:
 
1220
  logger.debug("LLMLinguaCompressor not available")
1221
  return self._llmlingua
1222
 
 
 
 
 
 
 
 
 
 
 
 
1223
  def _get_image_optimizer(self) -> Any:
1224
  """Get ImageCompressor (lazy load).
1225
 
headroom/transforms/kompress_compressor.py CHANGED
@@ -260,55 +260,55 @@ class KompressCompressor(Transform):
260
 
261
  try:
262
  model, tokenizer = _load_kompress(self.config.device)
 
263
 
264
- # Tokenize
265
- encoding = tokenizer(
266
- words,
267
- is_split_into_words=True,
268
- truncation=True,
269
- max_length=8192,
270
- padding=True,
271
- return_tensors="pt",
272
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
- device = next(model.parameters()).device
275
- input_ids = encoding["input_ids"].to(device)
276
- attention_mask = encoding["attention_mask"].to(device)
277
-
278
- word_ids = encoding.word_ids(batch_index=0)
279
-
280
- if target_ratio is not None:
281
- # User explicitly asked for a specific ratio — use scores + top-k
282
- scores = model.get_scores(input_ids, attention_mask)[0].cpu()
283
- word_scores: dict[int, float] = {}
284
- for idx, wid in enumerate(word_ids):
285
- if wid is None:
286
- continue
287
- s = scores[idx].item()
288
- if wid not in word_scores or s > word_scores[wid]:
289
- word_scores[wid] = s
290
- if not word_scores:
291
- return self._passthrough(content, n_words)
292
- sorted_wids = sorted(word_scores, key=lambda w: word_scores[w], reverse=True)
293
- num_keep = max(1, int(len(sorted_wids) * target_ratio))
294
- kept_ids = set(sorted_wids[:num_keep])
295
- else:
296
- # Model decides — no threshold, no ratio, just argmax
297
- keep_mask = model.get_keep_mask(input_ids, attention_mask)[0].cpu()
298
- # Map subword decisions to word-level (keep word if ANY subword says keep)
299
- word_keep: dict[int, bool] = {}
300
- for idx, wid in enumerate(word_ids):
301
- if wid is None:
302
- continue
303
- if keep_mask[idx].item():
304
- word_keep[wid] = True
305
- elif wid not in word_keep:
306
- word_keep[wid] = False
307
- kept_ids = {wid for wid, keep in word_keep.items() if keep}
308
- if not kept_ids:
309
- return self._passthrough(content, n_words)
310
-
311
- # Reconstruct in original word order
312
  compressed_words = [words[w] for w in sorted(kept_ids) if w < n_words]
313
  compressed = " ".join(compressed_words)
314
  compressed_count = len(compressed_words)
 
260
 
261
  try:
262
  model, tokenizer = _load_kompress(self.config.device)
263
+ device = next(model.parameters()).device
264
 
265
+ # Chunk at 512 tokens ≈ 350 words (matches training max_length)
266
+ max_chunk_words = 350
267
+ kept_ids: set[int] = set()
268
+
269
+ for chunk_start in range(0, n_words, max_chunk_words):
270
+ chunk_words = words[chunk_start : chunk_start + max_chunk_words]
271
+
272
+ encoding = tokenizer(
273
+ chunk_words,
274
+ is_split_into_words=True,
275
+ truncation=True,
276
+ max_length=512,
277
+ padding=True,
278
+ return_tensors="pt",
279
+ )
280
+
281
+ input_ids = encoding["input_ids"].to(device)
282
+ attention_mask = encoding["attention_mask"].to(device)
283
+ word_ids = encoding.word_ids(batch_index=0)
284
+
285
+ if target_ratio is not None:
286
+ scores = model.get_scores(input_ids, attention_mask)[0].cpu()
287
+ word_scores: dict[int, float] = {}
288
+ for idx, wid in enumerate(word_ids):
289
+ if wid is None:
290
+ continue
291
+ s = scores[idx].item()
292
+ if wid not in word_scores or s > word_scores[wid]:
293
+ word_scores[wid] = s
294
+ if word_scores:
295
+ sorted_wids = sorted(
296
+ word_scores, key=lambda w: word_scores[w], reverse=True
297
+ )
298
+ num_keep = max(1, int(len(sorted_wids) * target_ratio))
299
+ for wid in sorted_wids[:num_keep]:
300
+ kept_ids.add(wid + chunk_start)
301
+ else:
302
+ keep_mask = model.get_keep_mask(input_ids, attention_mask)[0].cpu()
303
+ for idx, wid in enumerate(word_ids):
304
+ if wid is None:
305
+ continue
306
+ if keep_mask[idx].item():
307
+ kept_ids.add(wid + chunk_start)
308
+
309
+ if not kept_ids:
310
+ return self._passthrough(content, n_words)
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  compressed_words = [words[w] for w in sorted(kept_ids) if w < n_words]
313
  compressed = " ".join(compressed_words)
314
  compressed_count = len(compressed_words)
headroom/transforms/pipeline.py CHANGED
@@ -80,7 +80,7 @@ class TransformPipeline:
80
  # 2. Content-aware Compression
81
  # ContentRouter handles ALL content types intelligently:
82
  # - JSON arrays -> SmartCrusher
83
- # - Plain text -> LLMLingua (ML-based) or TextCompressor
84
  # - Code -> CodeCompressor (AST-aware)
85
  # - Logs -> LogCompressor
86
  # - Search results -> SearchCompressor
 
80
  # 2. Content-aware Compression
81
  # ContentRouter handles ALL content types intelligently:
82
  # - JSON arrays -> SmartCrusher
83
+ # - Plain text -> Kompress (ML-based) or passthrough
84
  # - Code -> CodeCompressor (AST-aware)
85
  # - Logs -> LogCompressor
86
  # - Search results -> SearchCompressor
headroom/transforms/smart_crusher.py CHANGED
@@ -12,7 +12,7 @@ TEXT COMPRESSION IS OPT-IN: For text-based content, Headroom provides standalone
12
  utilities that applications can use explicitly:
13
  - SearchCompressor: For grep/ripgrep output (file:line:content format)
14
  - LogCompressor: For build/test logs (pytest, npm, cargo output)
15
- - TextCompressor: For generic plain text with anchor preservation
16
 
17
  Applications should decide when and how to use text compression based on their
18
  specific needs. This design prevents lossy text compression from being applied
 
12
  utilities that applications can use explicitly:
13
  - SearchCompressor: For grep/ripgrep output (file:line:content format)
14
  - LogCompressor: For build/test logs (pytest, npm, cargo output)
15
+ - Kompress: For generic plain text (ML-based, requires [ml] extra)
16
 
17
  Applications should decide when and how to use text compression based on their
18
  specific needs. This design prevents lossy text compression from being applied
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "headroom-ai"
7
- version = "0.4.5"
8
  description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
9
  readme = "README.md"
10
  license = "Apache-2.0"
@@ -59,6 +59,7 @@ proxy = [
59
  "uvicorn>=0.23.0",
60
  "httpx[http2]>=0.24.0",
61
  "openai>=2.14.0", # OpenAI API format support
 
62
  ]
63
  # AST-based code compression (tree-sitter)
64
  code = [
 
4
 
5
  [project]
6
  name = "headroom-ai"
7
+ version = "0.4.6"
8
  description = "The Context Optimization Layer for LLM Applications - Cut costs by 50-90%"
9
  readme = "README.md"
10
  license = "Apache-2.0"
 
59
  "uvicorn>=0.23.0",
60
  "httpx[http2]>=0.24.0",
61
  "openai>=2.14.0", # OpenAI API format support
62
+ "mcp>=1.0.0", # MCP server (headroom_compress, retrieve, stats)
63
  ]
64
  # AST-based code compression (tree-sitter)
65
  code = [
tests/test_cli/test_mcp.py CHANGED
@@ -361,7 +361,7 @@ class TestMCPServerInitialization:
361
 
362
  # Verify the server was created with correct configuration
363
  assert server.server is not None
364
- assert server.server.name == "headroom-ccr"
365
  # The tool name should be headroom_retrieve
366
  assert CCR_TOOL_NAME == "headroom_retrieve"
367
 
 
361
 
362
  # Verify the server was created with correct configuration
363
  assert server.server is not None
364
+ assert server.server.name == "headroom"
365
  # The tool name should be headroom_retrieve
366
  assert CCR_TOOL_NAME == "headroom_retrieve"
367
 
tests/test_text_compressors.py CHANGED
@@ -9,10 +9,9 @@ from headroom.transforms import (
9
  LogCompressorConfig,
10
  SearchCompressor,
11
  SearchCompressorConfig,
12
- TextCompressor,
13
- TextCompressorConfig,
14
  detect_content_type,
15
  )
 
16
 
17
 
18
  class TestContentDetector:
 
9
  LogCompressorConfig,
10
  SearchCompressor,
11
  SearchCompressorConfig,
 
 
12
  detect_content_type,
13
  )
14
+ from headroom.transforms.text_compressor import TextCompressor, TextCompressorConfig
15
 
16
 
17
  class TestContentDetector: