Spaces:
Build error
Build error
Commit ·
db23f5d
1
Parent(s): e8fad1d
docs: add OpenClaw plugin section to main README
Browse filesAdd dedicated OpenClaw plugin section with install instructions,
explanation of --dangerously-force-unsafe-install requirement (proxy
subprocess spawning), and quick config example. Links to plugin README
for full details. Update integration tables to reference new section.
Also fix proxy port handling: apply default proxyPort when explicit
proxyUrl omits port, and allow trailing slash in proxyUrl validation
pattern. Add test coverage for port defaulting behavior.
README.md
CHANGED
|
@@ -164,7 +164,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
|
|
| 164 |
| **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
|
| 165 |
| **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
|
| 166 |
| **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
|
| 167 |
-
| **OpenClaw** | ContextEngine plugin |
|
| 168 |
| **Claude Code** | Wrap | `headroom wrap claude` |
|
| 169 |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
|
| 170 |
|
|
@@ -363,6 +363,35 @@ Context compression is a new space. Here's how the approaches differ:
|
|
| 363 |
| MCP (Claude Code, Cursor, etc.) | **Stable** | [MCP Guide](docs/mcp.md) |
|
| 364 |
| Strands | **Stable** | [Strands Guide](docs/strands.md) |
|
| 365 |
| LangChain | **Stable** | [LangChain Guide](docs/langchain.md) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
|
| 367 |
---
|
| 368 |
|
|
|
|
| 164 |
| **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
|
| 165 |
| **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
|
| 166 |
| **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
|
| 167 |
+
| **OpenClaw** | ContextEngine plugin | [See OpenClaw plugin](#openclaw-plugin) |
|
| 168 |
| **Claude Code** | Wrap | `headroom wrap claude` |
|
| 169 |
| **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
|
| 170 |
|
|
|
|
| 363 |
| MCP (Claude Code, Cursor, etc.) | **Stable** | [MCP Guide](docs/mcp.md) |
|
| 364 |
| Strands | **Stable** | [Strands Guide](docs/strands.md) |
|
| 365 |
| LangChain | **Stable** | [LangChain Guide](docs/langchain.md) |
|
| 366 |
+
| **OpenClaw** | **Stable** | [OpenClaw plugin](#openclaw-plugin) |
|
| 367 |
+
|
| 368 |
+
---
|
| 369 |
+
|
| 370 |
+
## OpenClaw Plugin
|
| 371 |
+
|
| 372 |
+
The [`@headroom-ai/openclaw`](plugins/openclaw) plugin integrates Headroom as a ContextEngine for [OpenClaw](https://github.com/openclaw/openclaw). It compresses tool outputs, code, logs, and structured data inline — 70-90% token savings with zero LLM calls.
|
| 373 |
+
|
| 374 |
+
### Install
|
| 375 |
+
|
| 376 |
+
```bash
|
| 377 |
+
pip install "headroom-ai[proxy]"
|
| 378 |
+
openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
|
| 379 |
+
```
|
| 380 |
+
|
| 381 |
+
> **Why `--dangerously-force-unsafe-install`?** The plugin auto-starts `headroom proxy` as a subprocess when no running proxy is detected. OpenClaw blocks process-launching plugins by default, so this flag is required to permit that behavior.
|
| 382 |
+
|
| 383 |
+
Once installed, assign Headroom as the context engine in your OpenClaw config:
|
| 384 |
+
|
| 385 |
+
```json
|
| 386 |
+
{
|
| 387 |
+
"plugins": {
|
| 388 |
+
"entries": { "headroom": { "enabled": true } },
|
| 389 |
+
"slots": { "contextEngine": "headroom" }
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
```
|
| 393 |
+
|
| 394 |
+
The plugin auto-detects and auto-starts the proxy — no manual proxy management needed. See the [plugin README](plugins/openclaw/README.md) for full configuration options, local development setup, and launcher details.
|
| 395 |
|
| 396 |
---
|
| 397 |
|
plugins/openclaw/openclaw.plugin.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
| 23 |
},
|
| 24 |
"proxyUrl": {
|
| 25 |
"type": "string",
|
| 26 |
-
"pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$"
|
| 27 |
},
|
| 28 |
"proxyPort": {
|
| 29 |
"type": "integer",
|
|
|
|
| 23 |
},
|
| 24 |
"proxyUrl": {
|
| 25 |
"type": "string",
|
| 26 |
+
"pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?\\/?$"
|
| 27 |
},
|
| 28 |
"proxyPort": {
|
| 29 |
"type": "integer",
|
plugins/openclaw/src/proxy-manager.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
/**
|
| 2 |
-
* Manages connectivity to
|
| 3 |
*
|
| 4 |
* Security model:
|
| 5 |
* - Optional local process execution to auto-start Headroom proxy
|
|
@@ -59,13 +59,13 @@ export class ProxyManager {
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/**
|
| 62 |
-
|
| 63 |
*/
|
| 64 |
async start(): Promise<string> {
|
| 65 |
const port = this.getProxyPort();
|
| 66 |
const explicitUrl =
|
| 67 |
typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
|
| 68 |
-
? normalizeAndValidateProxyUrl(this.config.proxyUrl)
|
| 69 |
: null;
|
| 70 |
const defaultCandidates = this.getDefaultProxyCandidates(port);
|
| 71 |
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
|
|
@@ -102,7 +102,7 @@ export class ProxyManager {
|
|
| 102 |
this.logger.info(
|
| 103 |
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
|
| 104 |
);
|
| 105 |
-
await this.startHeadroomProxy(startupUrl);
|
| 106 |
|
| 107 |
const startedProbe = await waitForHeadroomProxy(
|
| 108 |
startupUrl,
|
|
@@ -144,7 +144,7 @@ export class ProxyManager {
|
|
| 144 |
}
|
| 145 |
|
| 146 |
/**
|
| 147 |
-
*
|
| 148 |
*/
|
| 149 |
async stop(): Promise<void> {
|
| 150 |
this.proxyUrl = null;
|
|
@@ -156,10 +156,10 @@ export class ProxyManager {
|
|
| 156 |
|
| 157 |
// --- Internal ---
|
| 158 |
|
| 159 |
-
private async startHeadroomProxy(proxyUrl: string): Promise<void> {
|
| 160 |
const parsed = new URL(proxyUrl);
|
| 161 |
const host = parsed.hostname;
|
| 162 |
-
const port = parsed.port ||
|
| 163 |
const specs = this.buildLaunchSpecs(host, port);
|
| 164 |
const errors: string[] = [];
|
| 165 |
|
|
@@ -319,6 +319,14 @@ export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
|
|
| 319 |
return parsed.origin;
|
| 320 |
}
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
/**
|
| 323 |
* Probe a configured URL and verify whether it is a running Headroom proxy.
|
| 324 |
*/
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Manages connectivity to a local Headroom proxy.
|
| 3 |
*
|
| 4 |
* Security model:
|
| 5 |
* - Optional local process execution to auto-start Headroom proxy
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
/**
|
| 62 |
+
* Ensure a proxy is available. Returns the normalized URL origin.
|
| 63 |
*/
|
| 64 |
async start(): Promise<string> {
|
| 65 |
const port = this.getProxyPort();
|
| 66 |
const explicitUrl =
|
| 67 |
typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
|
| 68 |
+
? withDefaultPort(normalizeAndValidateProxyUrl(this.config.proxyUrl), port)
|
| 69 |
: null;
|
| 70 |
const defaultCandidates = this.getDefaultProxyCandidates(port);
|
| 71 |
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
|
|
|
|
| 102 |
this.logger.info(
|
| 103 |
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
|
| 104 |
);
|
| 105 |
+
await this.startHeadroomProxy(startupUrl, port);
|
| 106 |
|
| 107 |
const startedProbe = await waitForHeadroomProxy(
|
| 108 |
startupUrl,
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
/**
|
| 147 |
+
* Stop manager state. Spawned proxy processes are detached and externally managed.
|
| 148 |
*/
|
| 149 |
async stop(): Promise<void> {
|
| 150 |
this.proxyUrl = null;
|
|
|
|
| 156 |
|
| 157 |
// --- Internal ---
|
| 158 |
|
| 159 |
+
private async startHeadroomProxy(proxyUrl: string, defaultPort: number): Promise<void> {
|
| 160 |
const parsed = new URL(proxyUrl);
|
| 161 |
const host = parsed.hostname;
|
| 162 |
+
const port = parsed.port || String(defaultPort);
|
| 163 |
const specs = this.buildLaunchSpecs(host, port);
|
| 164 |
const errors: string[] = [];
|
| 165 |
|
|
|
|
| 319 |
return parsed.origin;
|
| 320 |
}
|
| 321 |
|
| 322 |
+
function withDefaultPort(proxyUrl: string, defaultPort: number): string {
|
| 323 |
+
const parsed = new URL(proxyUrl);
|
| 324 |
+
if (!parsed.port) {
|
| 325 |
+
parsed.port = String(defaultPort);
|
| 326 |
+
}
|
| 327 |
+
return parsed.origin;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
/**
|
| 331 |
* Probe a configured URL and verify whether it is a running Headroom proxy.
|
| 332 |
*/
|
plugins/openclaw/test/proxy-manager.test.ts
CHANGED
|
@@ -104,6 +104,22 @@ describe("ProxyManager.start", () => {
|
|
| 104 |
await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
|
| 105 |
});
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
it("auto-starts when nothing is detected", async () => {
|
| 108 |
const manager = new ProxyManager({ autoStart: true });
|
| 109 |
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
|
|
@@ -119,7 +135,7 @@ describe("ProxyManager.start", () => {
|
|
| 119 |
|
| 120 |
const url = await manager.start();
|
| 121 |
expect(url).toBe("http://127.0.0.1:8787");
|
| 122 |
-
expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787");
|
| 123 |
});
|
| 124 |
});
|
| 125 |
|
|
|
|
| 104 |
await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
|
| 105 |
});
|
| 106 |
|
| 107 |
+
it("applies default proxyPort when explicit proxyUrl omits port", async () => {
|
| 108 |
+
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1", autoStart: true });
|
| 109 |
+
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
|
| 110 |
+
|
| 111 |
+
const fetchMock = vi
|
| 112 |
+
.fn()
|
| 113 |
+
.mockRejectedValueOnce(new Error("down"))
|
| 114 |
+
.mockResolvedValueOnce({ ok: true, status: 200 })
|
| 115 |
+
.mockResolvedValueOnce({ ok: true, status: 200 });
|
| 116 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 117 |
+
|
| 118 |
+
const url = await manager.start();
|
| 119 |
+
expect(url).toBe("http://127.0.0.1:8787");
|
| 120 |
+
expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
it("auto-starts when nothing is detected", async () => {
|
| 124 |
const manager = new ProxyManager({ autoStart: true });
|
| 125 |
const startSpy = vi.spyOn(manager as any, "startHeadroomProxy").mockResolvedValue(undefined);
|
|
|
|
| 135 |
|
| 136 |
const url = await manager.start();
|
| 137 |
expect(url).toBe("http://127.0.0.1:8787");
|
| 138 |
+
expect(startSpy).toHaveBeenCalledWith("http://127.0.0.1:8787", 8787);
|
| 139 |
});
|
| 140 |
});
|
| 141 |
|