chore: import upstream snapshot with attribution
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:03:20 +08:00
commit 0ef5fcb1c5
1951 changed files with 606278 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
/**
* Convert between OpenClaw's AgentMessage format and OpenAI message format.
*
* AgentMessage uses:
* role: "user" | "assistant" | "toolResult"
* content: string | ContentBlock[]
*
* OpenAI uses:
* role: "user" | "assistant" | "system" | "tool"
* content: string
* tool_calls?: ToolCall[]
* tool_call_id?: string
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface OpenAIMessage {
role: string;
content: string | null;
tool_calls?: any[];
tool_call_id?: string;
name?: string;
_headroomMeta?: Record<string, unknown>;
}
/**
* Convert AgentMessage[] to OpenAI message format for compression.
*/
export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
const result: OpenAIMessage[] = [];
for (const msg of messages) {
const normalized = normalizeAgentMessage(msg);
const role = normalized.role;
const buildMeta = (): Record<string, unknown> => {
const meta = { ...normalized } as Record<string, unknown>;
delete meta.role;
delete meta.content;
return meta;
};
if (role === "system") {
result.push({
role: "system",
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
if (role === "user") {
result.push({
role: "user",
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
if (role === "assistant") {
const content = normalized.content;
if (typeof content === "string") {
result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
continue;
}
// Content blocks: extract text and tool call blocks.
// OpenClaw uses `toolCall`; some adapters still emit legacy `tool_use`.
if (Array.isArray(content)) {
const textParts: string[] = [];
const toolCalls: any[] = [];
for (const block of content) {
if (typeof block === "string") {
textParts.push(block);
} else if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "tool_use" || block.type === "toolCall") {
const args =
block.type === "toolCall"
? block.arguments
: block.input;
toolCalls.push({
id: block.id,
type: "function",
function: {
name: block.name,
arguments:
typeof args === "string"
? args
: JSON.stringify(args ?? {}),
},
});
}
}
const openaiMsg: OpenAIMessage = {
role: "assistant",
content: textParts.length > 0 ? textParts.join("") : null,
_headroomMeta: buildMeta(),
};
if (toolCalls.length > 0) {
openaiMsg.tool_calls = toolCalls;
}
result.push(openaiMsg);
}
continue;
}
if (role === "toolResult" || role === "tool_result") {
const content =
typeof normalized.content === "string"
? normalized.content
: Array.isArray(normalized.content)
? extractText(normalized.content)
: JSON.stringify(normalized.content);
result.push({
role: "tool",
content,
tool_call_id:
normalized.toolCallId ??
normalized.tool_use_id ??
normalized.id ??
"unknown",
_headroomMeta: buildMeta(),
});
continue;
}
// Fallback: pass through as user message
result.push({
role: "user",
content:
typeof normalized.content === "string"
? normalized.content
: JSON.stringify(normalized.content),
_headroomMeta: buildMeta(),
});
}
return result;
}
/**
* Convert compressed OpenAI messages back to AgentMessage format.
*/
export function openAIToAgent(messages: OpenAIMessage[]): any[] {
const result: any[] = [];
for (const msg of messages) {
const meta = (msg._headroomMeta ?? {}) as Record<string, unknown>;
const timestamp =
typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
if (msg.role === "system") {
result.push({
role: "system",
content: msg.content ?? "",
timestamp,
});
continue;
}
if (msg.role === "user") {
result.push({
role: "user",
content: msg.content ?? "",
timestamp,
});
continue;
}
if (msg.role === "assistant") {
const blocks: any[] = [];
if (msg.content) {
blocks.push({ type: "text", text: msg.content });
}
if (msg.tool_calls) {
for (const tc of msg.tool_calls) {
let input: any;
try {
input = JSON.parse(tc.function.arguments);
} catch {
input = tc.function.arguments ?? {};
}
// Emit OpenClaw-native block shape so downstream transports keep call linkage.
blocks.push({
type: "toolCall",
id: tc.id,
name: tc.function.name,
arguments: input,
});
}
}
// OpenClaw's Pi agent expects content to always be an array for assistant messages
// (it calls .flatMap() on it). Never flatten to a string.
result.push({
...(meta as object),
role: "assistant",
content: blocks,
api: typeof meta.api === "string" ? meta.api : "headroom",
provider: typeof meta.provider === "string" ? meta.provider : "headroom",
model: typeof meta.model === "string" ? meta.model : "headroom",
usage:
isRecord(meta.usage)
? meta.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason:
typeof meta.stopReason === "string" ? meta.stopReason : "stop",
timestamp,
});
continue;
}
if (msg.role === "tool") {
const textContent =
typeof msg.content === "string"
? msg.content
: msg.content == null
? ""
: JSON.stringify(msg.content);
const toolCallId = msg.tool_call_id ?? "unknown";
result.push({
...(meta as object),
role: "toolResult",
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
content: [{ type: "text", text: textContent }],
toolCallId:
typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
tool_use_id:
typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
toolName:
typeof meta.toolName === "string" ? meta.toolName : "headroom",
isError: typeof meta.isError === "boolean" ? meta.isError : false,
timestamp,
});
continue;
}
}
return result;
}
export function normalizeAgentMessages(messages: any[]): any[] {
return messages.map((message) => normalizeAgentMessage(message));
}
/**
* Extract text from content blocks.
*/
function extractText(content: any): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return JSON.stringify(content);
return content
.map((block: any) => {
if (typeof block === "string") return block;
if (block.type === "text") return block.text;
if (block.type === "tool_result") {
return typeof block.content === "string" ? block.content : JSON.stringify(block.content);
}
return "";
})
.filter(Boolean)
.join("\n");
}
function normalizeAgentMessage(message: any): any {
if (!isRecord(message)) return message;
if (message.role === "assistant") {
return normalizeAssistantMessage(message);
}
if (message.role === "toolResult" || message.role === "tool_result") {
return normalizeToolResultMessage(message);
}
return message;
}
function normalizeAssistantMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeAssistantContent(message.content);
return {
...message,
content: normalizedContent,
api: typeof message.api === "string" ? message.api : "headroom",
provider: typeof message.provider === "string" ? message.provider : "headroom",
model: typeof message.model === "string" ? message.model : "headroom",
usage: isRecord(message.usage)
? message.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeToolResultMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeToolResultContent(message.content);
const toolCallId =
typeof message.toolCallId === "string"
? message.toolCallId
: typeof message.tool_use_id === "string"
? message.tool_use_id
: typeof message.id === "string"
? message.id
: "unknown";
return {
...message,
role: "toolResult",
content: normalizedContent,
toolCallId,
tool_use_id:
typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
isError: typeof message.isError === "boolean" ? message.isError : false,
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeAssistantContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (block.type === "thinking" && typeof block.thinking === "string") return [block];
if (
(block.type === "toolCall" || block.type === "tool_use") &&
typeof block.name === "string"
) {
return [
{
type: "toolCall",
id: typeof block.id === "string" ? block.id : "unknown",
name: block.name,
arguments:
"arguments" in block
? block.arguments
: "input" in block
? block.input
: {},
},
];
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function normalizeToolResultContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (
block.type === "image" &&
typeof block.data === "string" &&
typeof block.mimeType === "string"
) {
return [block];
}
if (block.type === "tool_result" && "content" in block) {
return normalizeToolResultContent(block.content);
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+301
View File
@@ -0,0 +1,301 @@
/**
* HeadroomContextEngine — ContextEngine implementation for OpenClaw.
*
* Compresses tool outputs and conversation context using the Headroom proxy.
* Zero LLM calls — all compression is algorithmic (SmartCrusher, ContentRouter, etc.)
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { compress } from "headroom-ai";
import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export interface HeadroomEngineConfig extends ProxyManagerConfig {
enabled?: boolean;
}
export class HeadroomContextEngine {
readonly info = {
id: "headroom",
name: "Headroom Context Compression",
version: "0.1.0",
ownsCompaction: true,
};
private proxyManager: ProxyManager;
private proxyUrl: string | null = null;
private config: HeadroomEngineConfig;
private logger: ProxyManagerLogger;
private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
private proxyStartupPromise: Promise<string> | null = null;
private proxyStartupError: unknown = null;
private stats = {
totalCompressions: 0,
totalTokensSaved: 0,
totalTokensBefore: 0,
compactions: 0,
};
constructor(config: HeadroomEngineConfig = {}, logger?: ProxyManagerLogger) {
this.config = config;
this.logger = logger ?? defaultLogger;
this.proxyManager = new ProxyManager(config, this.logger);
}
// === ContextEngine Lifecycle ===
async bootstrap(params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
}): Promise<{ bootstrapped: boolean; reason?: string }> {
if (this.config.enabled === false) {
return { bootstrapped: false, reason: "disabled" };
}
this.ensureProxyStarted();
return { bootstrapped: true, reason: "proxy startup scheduled" };
}
async ingest(params: {
sessionId: string;
message: any;
isHeartbeat?: boolean;
}): Promise<{ ingested: boolean }> {
// No-op: OpenClaw's runtime stores messages. We don't need a separate store.
return { ingested: true };
}
async ingestBatch?(params: {
sessionId: string;
messages: any[];
isHeartbeat?: boolean;
}): Promise<{ ingestedCount: number }> {
return { ingestedCount: params.messages.length };
}
/**
* Assemble context for the model — THE CORE HOOK.
*
* Converts AgentMessage[] → OpenAI format → compress() → AgentMessage[]
*/
async assemble(params: {
sessionId: string;
messages: any[];
tokenBudget?: number;
model?: string;
prompt?: string;
}): Promise<{
messages: any[];
estimatedTokens: number;
systemPromptAddition?: string;
}> {
if (!this.proxyUrl || this.config.enabled === false) {
this.ensureProxyStarted();
// Fallback: return messages unchanged
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
try {
// Convert AgentMessage → OpenAI format
const openaiMessages = agentToOpenAI(params.messages);
// Compress via proxy — pass tokenBudget so RollingWindow enforces it
const result = await compress(openaiMessages, {
model: params.model ?? "claude-sonnet-4-5",
baseUrl: this.proxyUrl,
fallback: true,
tokenBudget: params.tokenBudget,
} as any);
if (!result.compressed || result.tokensSaved === 0) {
return {
messages: normalizeAgentMessages(params.messages),
estimatedTokens: result.tokensBefore,
};
}
// Convert back to AgentMessage format
const compressedAgentMessages = openAIToAgent(result.messages);
// Track stats
this.stats.totalCompressions++;
this.stats.totalTokensSaved += result.tokensSaved;
this.stats.totalTokensBefore += result.tokensBefore;
this.logger.debug(
`Assembled: ${result.tokensBefore}${result.tokensAfter} tokens (saved ${result.tokensSaved})`,
);
return {
messages: compressedAgentMessages,
estimatedTokens: result.tokensAfter,
systemPromptAddition:
result.tokensSaved > 100
? `[Context compressed by Headroom: ${result.tokensSaved} tokens saved. Use headroom_retrieve with the hash to get full details.]`
: undefined,
};
} catch (error) {
this.logger.error(`Assemble failed: ${error}`);
// Graceful fallback: return original messages
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
}
/**
* Compact context — zero-cost alternative to LLM summarization.
*
* Calls compress() with the token budget, which triggers:
* - SmartCrusher: aggressive JSON compression (70-90% on tool outputs)
* - Kompress: ModernBERT text compression (40-60% on assistant text)
* - RollingWindow: drops oldest messages if still over budget
* - CCR: stores originals for retrieval via headroom_retrieve tool
*
* Zero LLM calls. All algorithmic.
*/
async compact(params: {
sessionId: string;
sessionFile: string;
tokenBudget?: number;
force?: boolean;
runtimeContext?: any;
}): Promise<{
ok: boolean;
compacted: boolean;
reason?: string;
result?: {
tokensBefore: number;
tokensAfter?: number;
};
}> {
if (!this.proxyUrl) {
return { ok: false, compacted: false, reason: "Proxy not available" };
}
// Read current messages from session file if available
// For now, compact() works in tandem with assemble() — the next assemble()
// call will compress with the token budget. When compact() is called
// independently, we report success since our pipeline handles it.
//
// TODO: Read session file, extract messages, call compress() with tokenBudget,
// write back compacted messages.
this.stats.compactions++;
this.logger.info(
`Compact called (budget: ${params.tokenBudget ?? "none"}, force: ${params.force ?? false})`,
);
return {
ok: true,
compacted: true,
reason: "Headroom applies SmartCrusher + Kompress + RollingWindow on next assemble()",
};
}
async afterTurn?(params: {
sessionId: string;
messages: any[];
prePromptMessageCount: number;
isHeartbeat?: boolean;
}): Promise<void> {
// Optional: could log stats or trigger learning
}
async prepareSubagentSpawn?(params: {
parentSessionKey: string;
childSessionKey: string;
ttlMs?: number;
}): Promise<{ rollback: () => Promise<void> } | undefined> {
// Subagent context is compressed naturally via assemble()
return undefined;
}
async onSubagentEnded?(params: {
childSessionKey: string;
reason: string;
}): Promise<void> {
// No-op
}
async dispose(): Promise<void> {
await this.proxyManager.stop();
this.logger.info(
`Engine disposed. Stats: ${this.stats.totalCompressions} compressions, ` +
`${this.stats.totalTokensSaved} tokens saved`,
);
}
// --- Public API ---
getStats() {
return { ...this.stats };
}
getProxyUrl(): string | null {
return this.proxyUrl;
}
getProxyStartupError(): unknown {
return this.proxyStartupError;
}
ensureProxyStarted(): void {
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
return;
}
this.proxyStartupError = null;
this.proxyStartupPromise = this.proxyManager
.start()
.then(async (proxyUrl) => {
this.proxyUrl = proxyUrl;
this.proxyStartupError = null;
await this.notifyProxyReady(proxyUrl);
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
return proxyUrl;
})
.catch((error) => {
this.proxyStartupError = error;
this.logger.warn(`Headroom proxy unavailable: ${error}`);
throw error;
})
.finally(() => {
this.proxyStartupPromise = null;
});
// Fire-and-forget lifecycle callers intentionally do not await this promise.
// Keep the promise rejectable for ensureProxyUrl(), but mark it observed so
// a missing proxy cannot become a process-level unhandled rejection.
void this.proxyStartupPromise.catch(() => {});
}
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
this.proxyReadyListeners.add(listener);
return () => {
this.proxyReadyListeners.delete(listener);
};
}
async ensureProxyUrl(): Promise<string> {
if (this.proxyUrl) {
return this.proxyUrl;
}
this.ensureProxyStarted();
if (!this.proxyStartupPromise) {
throw new Error("Headroom proxy startup is disabled");
}
return this.proxyStartupPromise;
}
private async notifyProxyReady(proxyUrl: string): Promise<void> {
for (const listener of this.proxyReadyListeners) {
try {
await listener(proxyUrl);
} catch (error) {
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
}
}
}
}
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"] as const;
const DEFAULT_PROVIDER_BASE_URLS: Readonly<Record<string, string>> = {
"openai-codex": "https://chatgpt.com/backend-api",
};
const GATEWAY_PROVIDER_ID_ALIASES: Readonly<Record<string, string>> = {
codex: "openai-codex",
claude: "anthropic",
copilot: "github-copilot",
gemini: "google",
};
const EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = new Set<string>(["github-copilot"]);
export function resolveGatewayProviderIds(config: Record<string, unknown> | undefined): string[] {
const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
if (configuredProviderIds.length > 0) {
return configuredProviderIds;
}
if (config?.routeCodexViaProxy === false) {
return [];
}
return [...DEFAULT_GATEWAY_PROVIDER_IDS];
}
function normalizeGatewayProviderIds(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") {
continue;
}
const rawProviderId = entry.trim();
const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
if (!providerId || seen.has(providerId)) {
continue;
}
seen.add(providerId);
normalized.push(providerId);
}
return normalized;
}
export function applyGatewayProviderBaseUrls<T>(
cfg: T,
proxyUrl: string,
providerIds: readonly string[],
): { changed: boolean; config: T } {
const next = structuredClone((cfg ?? {}) as any);
const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
return { changed, config: next as T };
}
export function applyGatewayProviderBaseUrlsInPlace(
cfg: any,
proxyUrl: string,
providerIds: readonly string[],
): boolean {
if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
return false;
}
const models = (cfg.models ??= {});
const providers = (models.providers ??= {});
let changed = false;
for (const providerId of providerIds) {
const currentValue = providers[providerId];
const currentConfig =
currentValue && typeof currentValue === "object" && !Array.isArray(currentValue)
? currentValue
: {};
const nextConfig = { ...currentConfig };
const currentBaseUrl =
typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0
? nextConfig.baseUrl
: undefined;
const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
if (
!currentBaseUrl &&
!defaultBaseUrl &&
EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)
) {
continue;
}
const nextBaseUrl = routeBaseUrlThroughProxy({
providerId,
proxyUrl,
currentBaseUrl,
});
if (!Array.isArray(nextConfig.models)) {
nextConfig.models = [];
changed = true;
}
if (nextConfig.baseUrl === nextBaseUrl) {
providers[providerId] = nextConfig;
continue;
}
nextConfig.baseUrl = nextBaseUrl;
providers[providerId] = nextConfig;
changed = true;
}
return changed;
}
function routeBaseUrlThroughProxy(params: {
providerId: string;
proxyUrl: string;
currentBaseUrl?: string;
}): string {
const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
if (!upstreamBaseUrl) {
return params.proxyUrl;
}
try {
const proxy = new URL(params.proxyUrl);
const upstream = new URL(upstreamBaseUrl);
proxy.pathname = upstream.pathname;
proxy.search = upstream.search;
proxy.hash = "";
return proxy.toString().replace(/\/$/, "");
} catch {
return params.proxyUrl;
}
}
+11
View File
@@ -0,0 +1,11 @@
export { default } from "./plugin/index.js";
export { HeadroomContextEngine } from "./engine.js";
export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
export {
DEFAULT_GATEWAY_PROVIDER_IDS,
applyGatewayProviderBaseUrls,
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "./gateway-config.js";
+142
View File
@@ -0,0 +1,142 @@
/**
* Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
*
* Usage:
* openclaw plugins install headroom-ai/openclaw
*
* Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
* {
* "plugins": {
* "slots": { "contextEngine": "headroom" },
* "entries": { "headroom": { "enabled": true } }
* }
* }
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HeadroomContextEngine } from "../engine.js";
import {
applyGatewayProviderBaseUrlsInPlace,
resolveGatewayProviderIds,
} from "../gateway-config.js";
import { normalizeAndValidateProxyUrl, probeHeadroomProxy } from "../proxy-manager.js";
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
/**
* OpenClaw 2026.x plugin API requires a `{ register(api) }` object export.
* The previous bare-function default export was silently skipped by the loader.
* See: https://github.com/chopratejas/headroom/issues/XXX
*/
export default {
register: headroomPlugin,
};
function headroomPlugin(api: any) {
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
const logger = api.logger ?? console;
const rawProxyUrl = config.proxyUrl;
const proxyUrl =
typeof rawProxyUrl === "string" && rawProxyUrl.trim().length > 0
? normalizeAndValidateProxyUrl(rawProxyUrl)
: undefined;
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
info: (m: string) => logger.info(m),
warn: (m: string) => logger.warn(m),
error: (m: string) => logger.error(m),
debug: (m: string) => logger.debug?.(m),
});
const gatewayProviderIds = resolveGatewayProviderIds(config);
let validatedConfiguredProxyUrl: string | null = null;
let configuredProxyProbePromise: Promise<string | null> | null = null;
const applyGatewayRouting = async (activeProxyUrl: string) => {
if (gatewayProviderIds.length === 0) {
return;
}
try {
const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
if (changed) {
logger.info(
`[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`,
);
} else {
logger.info(
`[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`,
);
}
} catch (error) {
logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
}
};
const getConfiguredRoutingProxyUrl = async (): Promise<string | null> => {
if (!proxyUrl) {
return null;
}
if (validatedConfiguredProxyUrl === proxyUrl) {
return validatedConfiguredProxyUrl;
}
if (!configuredProxyProbePromise) {
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl)
.then((probe) => {
if (probe.reachable && probe.isHeadroom) {
validatedConfiguredProxyUrl = proxyUrl;
return proxyUrl;
}
logger.warn(
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` +
(probe.reason ? ` (${probe.reason})` : ""),
);
return null;
})
.catch((error) => {
logger.warn(
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`,
);
return null;
})
.finally(() => {
configuredProxyProbePromise = null;
});
}
return configuredProxyProbePromise;
};
const ensureGatewayRouting = async () => {
if (gatewayProviderIds.length === 0) {
return;
}
const activeProxyUrl = engine.getProxyUrl() ?? (await getConfiguredRoutingProxyUrl());
if (!activeProxyUrl) {
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
return;
}
await applyGatewayRouting(activeProxyUrl);
};
engine.onProxyReady(async (activeProxyUrl) => {
await applyGatewayRouting(activeProxyUrl);
});
// Register as context engine
api.registerContextEngine("headroom", () => engine);
// Register CCR retrieval tool (active once proxy is running)
api.registerTool((ctx: any) => {
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
if (!activeProxyUrl) return null;
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
}, { names: ["headroom_retrieve"] });
api.on("gateway_start", async () => {
await ensureGatewayRouting();
});
void ensureGatewayRouting();
logger.info("[headroom] Plugin registered");
}
+528
View File
@@ -0,0 +1,528 @@
/**
* Manages connectivity to a Headroom proxy (local or remote).
*
* Security model:
* - Local proxies (127.0.0.1 / localhost) can be auto-started via subprocess
* - Remote proxies are connect-only: probe and use, never launch
* - No environment variable access
*/
import { spawn } from "node:child_process";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import os from "os";
export interface ProxyManagerConfig {
proxyUrl?: string;
proxyPort?: number;
pythonPath?: string;
autoStart?: boolean;
startupTimeoutMs?: number;
retryMaxAttempts?: number;
connectTimeoutSeconds?: number;
}
export interface ProxyManagerLogger {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
debug(message: string): void;
}
/** Default logger that prefixes all messages with `[headroom]`. */
export const defaultLogger: ProxyManagerLogger = {
info: (m) => console.log(`[headroom] ${m}`),
warn: (m) => console.warn(`[headroom] ${m}`),
error: (m) => console.error(`[headroom] ${m}`),
debug: () => {},
};
export interface ProxyProbeResult {
reachable: boolean;
isHeadroom: boolean;
reason?: string;
}
interface LaunchSpec {
label: string;
command: string;
args: string[];
checkCommand: string;
checkArgs: string[];
useShell?: boolean;
checkUseShell?: boolean;
}
const HEADROOM_MODULE_DISCOVERY_SNIPPET =
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
export class ProxyManager {
private config: ProxyManagerConfig;
private logger: ProxyManagerLogger;
private proxyUrl: string | null = null;
constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
this.config = config;
this.logger = logger ?? defaultLogger;
}
/**
* Ensure a proxy is available. Returns the normalized URL origin.
*/
async start(): Promise<string> {
const port = this.getProxyPort();
const rawExplicitUrl =
typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
? normalizeAndValidateProxyUrl(this.config.proxyUrl)
: null;
// Only apply proxyPort default to local URLs — remote URLs use their protocol default
const explicitUrl = rawExplicitUrl
? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl
: null;
const defaultCandidates = this.getDefaultProxyCandidates(port);
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
const probeByUrl = new Map<string, ProxyProbeResult>();
for (const url of candidateUrls) {
const probe = await probeHeadroomProxy(url);
probeByUrl.set(url, probe);
if (probe.reachable && probe.isHeadroom) {
this.proxyUrl = url;
this.logger.info(`Headroom proxy already running at ${url}`);
return url;
}
}
if (explicitUrl) {
const explicitProbe = probeByUrl.get(explicitUrl);
if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
throw new Error(
`Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`,
);
}
}
// Remote URLs are connect-only — never auto-start a subprocess for them
if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
throw new Error(
`Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`,
);
}
// Auto-start is only available for local proxies
if (this.config.autoStart === true) {
const startupUrl = explicitUrl ?? defaultCandidates[0];
const startupProbe = probeByUrl.get(startupUrl);
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
throw new Error(
`Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`,
);
}
this.logger.info(
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
);
await this.startHeadroomProxy(startupUrl, port);
const startedProbe = await waitForHeadroomProxy(
startupUrl,
this.config.startupTimeoutMs ?? 20_000,
);
if (startedProbe.reachable && startedProbe.isHeadroom) {
this.proxyUrl = startupUrl;
this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
return startupUrl;
}
throw new Error(
`Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`,
);
}
if (explicitUrl) {
throw new Error(
`Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`,
);
}
throw new Error(
`Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). ` +
"Set proxyUrl explicitly or enable autoStart.",
);
}
private getProxyPort(): number {
const rawPort = this.config.proxyPort;
if (!Number.isInteger(rawPort) || rawPort === undefined) return 8787;
if (rawPort < 1 || rawPort > 65535) {
throw new Error("proxyPort must be an integer between 1 and 65535");
}
return rawPort;
}
private getDefaultProxyCandidates(port: number): string[] {
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
}
/**
* Stop manager state. Spawned proxy processes are detached and externally managed.
*/
async stop(): Promise<void> {
this.proxyUrl = null;
}
getUrl(): string | null {
return this.proxyUrl;
}
// --- Internal ---
private async startHeadroomProxy(proxyUrl: string, defaultPort: number): Promise<void> {
const parsed = new URL(proxyUrl);
const host = parsed.hostname;
const port = parsed.port || String(defaultPort);
const specs = this.buildLaunchSpecs(host, port);
const errors: string[] = [];
for (const spec of specs) {
if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
this.logger.debug(`Launcher unavailable: ${spec.label}`);
continue;
}
try {
const child = spawn(spec.command, spec.args, {
detached: true,
shell: spec.useShell === true,
stdio: "ignore",
});
child.unref();
this.logger.info(`Auto-start launcher selected: ${spec.label}`);
return;
} catch (error) {
errors.push(`${spec.label}: ${String(error)}`);
}
}
throw new Error(
"No usable Headroom launcher found. Tried PATH, local npm, global npm, and Python. " +
"Install headroom-ai (npm or pip) and ensure one launcher is available.\n" +
(errors.length > 0 ? `Launch errors: ${errors.join("; ")}` : ""),
);
}
private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
const commonArgs = ["proxy", "--host", host, "--port", port];
const retryMaxAttempts = this.config.retryMaxAttempts;
if (Number.isInteger(retryMaxAttempts)) {
commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
}
const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
if (Number.isInteger(connectTimeoutSeconds)) {
commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
}
const specs: LaunchSpec[] = [];
const configuredPython = this.getConfiguredPythonCommand();
if (configuredPython) {
specs.push({
label: `Configured Python: ${configuredPython} -m headroom.cli`,
command: configuredPython,
args: ["-m", "headroom.cli", ...commonArgs],
checkCommand: configuredPython,
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
});
}
// 2) Windows pyenv: resolve the real executable so we avoid shim .bat wrappers.
if (process.platform === "win32") {
const pyenvHeadroom = this.getPyenvResolvedHeadroom();
if (pyenvHeadroom) {
specs.push({
label: `pyenv: ${pyenvHeadroom}`,
command: pyenvHeadroom,
args: commonArgs,
checkCommand: pyenvHeadroom,
checkArgs: ["--version"],
useShell: false,
});
}
}
// 3) PATH
specs.push({
label: "PATH: headroom",
command: "headroom",
args: commonArgs,
checkCommand: process.platform === "win32" ? "where.exe" : "sh",
checkArgs: process.platform === "win32"
? ["headroom"]
: ["-c", "command -v headroom >/dev/null 2>&1"],
useShell: process.platform === "win32",
checkUseShell: false,
});
// 4) uv tool install path (~/.local/bin/headroom)
const uvBin = join(
os.homedir(),
".local", "bin", "headroom"
);
if (existsSync(uvBin)) {
specs.push({
label: `uv tool: ${uvBin}`,
command: uvBin,
args: commonArgs,
checkCommand: uvBin,
checkArgs: ["--version"],
});
}
// 5) Local npm install (inside plugin install path)
const moduleDir = dirname(fileURLToPath(import.meta.url)); // .../dist
const packageRoot = dirname(moduleDir);
const localBinDir = join(packageRoot, "node_modules", ".bin");
const localBins = process.platform === "win32"
? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")]
: [join(localBinDir, "headroom")];
for (const localBin of localBins) {
if (!existsSync(localBin)) continue;
specs.push({
label: `Local npm: ${localBin}`,
command: localBin,
args: commonArgs,
checkCommand: localBin,
checkArgs: ["--version"],
useShell: process.platform === "win32",
});
}
// 5) Global npm install
const npmPrefix = this.getNpmGlobalPrefix();
if (npmPrefix) {
const globalBins = process.platform === "win32"
? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")]
: [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];
for (const globalBin of globalBins) {
if (!existsSync(globalBin)) continue;
specs.push({
label: `Global npm: ${globalBin}`,
command: globalBin,
args: commonArgs,
checkCommand: globalBin,
checkArgs: ["--version"],
useShell: process.platform === "win32",
});
}
}
// 6) Python module fallback
const pythonCommands = this.getPythonCommands();
for (const pyCmd of pythonCommands) {
if (configuredPython && pyCmd === configuredPython) continue;
specs.push({
label: `Python: ${pyCmd} -m headroom.cli`,
command: pyCmd,
args: ["-m", "headroom.cli", ...commonArgs],
checkCommand: pyCmd,
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
});
}
return specs;
}
private getConfiguredPythonCommand(): string | null {
const configured = typeof this.config.pythonPath === "string"
? this.config.pythonPath.trim()
: "";
return configured.length > 0 ? configured : null;
}
private getPyenvResolvedHeadroom(): string | null {
if (process.platform !== "win32") return null;
try {
const result = spawnSync("pyenv", ["which", "headroom"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
if (result.error || result.status !== 0) return null;
const resolved = (result.stdout ?? "").trim().split(/\r?\n/, 1)[0];
if (!resolved || !existsSync(resolved)) return null;
return resolved;
} catch {
return null;
}
}
private getPythonCommands(): string[] {
const commands: string[] = [];
const configured = this.getConfiguredPythonCommand() ?? "";
if (configured.length > 0) {
commands.push(configured);
}
for (const fallback of ["python", "python3", "py"]) {
if (!commands.includes(fallback)) commands.push(fallback);
}
return commands;
}
private canExecute(command: string, args: string[], useShell = false): boolean {
try {
const result = spawnSync(command, args, {
shell: useShell,
stdio: "ignore",
timeout: 5000,
});
if (result.error) return false;
return result.status === 0;
} catch {
return false;
}
}
private getNpmGlobalPrefix(): string | null {
try {
const result = spawnSync("npm", ["prefix", "-g"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
if (result.error || result.status !== 0) return null;
const prefix = (result.stdout ?? "").trim();
return prefix.length > 0 ? prefix : null;
} catch {
return null;
}
}
}
/** Parse a URL, returning the parsed object or throwing a descriptive error. */
function parseProxyUrl(proxyUrl: string): URL {
try {
return new URL(proxyUrl);
} catch {
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
}
}
export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
const parsed = parseProxyUrl(proxyUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("proxyUrl must use http:// or https://");
}
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
throw new Error("proxyUrl must not include a path, query, or hash");
}
return parsed.origin;
}
/** Returns true if the URL points to a local address (localhost or 127.0.0.1). */
export function isLocalProxyUrl(proxyUrl: string): boolean {
try {
const parsed = new URL(proxyUrl);
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
} catch {
return false;
}
}
function withDefaultPort(proxyUrl: string, defaultPort: number): string {
const parsed = parseProxyUrl(proxyUrl);
if (!parsed.port) {
parsed.port = String(defaultPort);
}
return parsed.origin;
}
/**
* Probe a configured URL and verify whether it is a running Headroom proxy.
*/
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
const origin = normalizeAndValidateProxyUrl(proxyUrl);
const probeEndpoint = async (
path: string,
options: { readBody?: boolean } = {},
): Promise<{ reachable: boolean; ok: boolean; status?: number; body?: string }> => {
try {
const response = await fetch(`${origin}${path}`, {
signal: AbortSignal.timeout(3_000),
});
const body =
response.ok && options.readBody
? await response.text().catch(() => undefined)
: undefined;
return { reachable: true, ok: response.ok, status: response.status, body };
} catch {
return { reachable: false, ok: false };
}
};
const ready = await probeEndpoint("/readyz");
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
return { reachable: true, isHeadroom: true };
}
const stats = await probeEndpoint("/stats", { readBody: true });
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
return { reachable: true, isHeadroom: true };
}
const health = await probeEndpoint("/health");
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
if (!anyReachable) {
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
}
const reasons = [
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
retrieveStats.reachable
? `retrieve stats HTTP ${retrieveStats.status}`
: "retrieve stats endpoint unavailable",
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
health.reachable ? `health HTTP ${health.status}` : "health check failed",
];
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
}
function hasHeadroomStatsShape(body: string | undefined): boolean {
if (!body) {
return false;
}
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return (
parsed !== null &&
typeof parsed === "object" &&
(Object.hasOwn(parsed, "proxy_inbound") ||
Object.hasOwn(parsed, "api_requests") ||
Object.hasOwn(parsed, "provider_tokens") ||
Object.hasOwn(parsed, "proxy_compression_saved") ||
Object.hasOwn(parsed, "store") ||
Object.hasOwn(parsed, "recent_retrievals"))
);
} catch {
return false;
}
}
async function waitForHeadroomProxy(proxyUrl: string, timeoutMs: number): Promise<ProxyProbeResult> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = await probeHeadroomProxy(proxyUrl);
if (result.reachable && result.isHeadroom) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
return probeHeadroomProxy(proxyUrl);
}
@@ -0,0 +1,70 @@
/**
* CCR (Compress-Cache-Retrieve) tool for OpenClaw.
*
* Allows the agent to retrieve original uncompressed content
* from the Headroom proxy's compression store.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
export interface RetrieveToolConfig {
proxyUrl: string;
}
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
return {
name: "headroom_retrieve",
description:
"Retrieve original uncompressed content from Headroom's compression store. " +
"Use when compressed context mentions a hash and you need the full details. " +
"Pass the hash from the compression marker (24 hex characters). " +
"Retrieval is by hash and always returns the full original content.",
parameters: {
type: "object" as const,
properties: {
hash: {
type: "string",
description: "The 24-character hex hash from the compression marker",
},
},
required: ["hash"],
},
execute: async (args: { hash: string }): Promise<string> => {
const { hash } = args;
// Validate hash format
if (!/^[a-f0-9]{24}$/i.test(hash)) {
return JSON.stringify({
error: "Invalid hash format. Expected 24 hex characters.",
});
}
try {
const url = `${proxyOrigin}/v1/retrieve/${hash}`;
const resp = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
const body = await resp.text().catch(() => "");
return JSON.stringify({
error: `Retrieval failed: HTTP ${resp.status}`,
details: body,
});
}
const data = await resp.json();
return typeof data === "string" ? data : JSON.stringify(data);
} catch (error) {
return JSON.stringify({
error: `Retrieval failed: ${error}`,
hint: "The compressed content may have expired (default TTL: 5 minutes)",
});
}
},
};
}