JerrettDavis commited on
Commit
db23f5d
·
1 Parent(s): e8fad1d

docs: add OpenClaw plugin section to main README

Browse files

Add 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 | `openclaw plugins install headroom-openclaw` |
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 an externally managed Headroom proxy.
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
- * 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
- ? 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
- * No-op: plugin never starts or manages external processes.
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 || "80";
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