headroom-openclaw 0.1.0 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,14 +3,11 @@ import { compress } from "headroom-ai";
3
3
 
4
4
  // src/proxy-manager.ts
5
5
  import { spawn } from "child_process";
6
- import { createWriteStream } from "fs";
7
- import { join } from "path";
8
- import { homedir } from "os";
9
- var DEFAULT_PORT = 8787;
10
- var HEALTH_CHECK_INTERVAL_MS = 3e4;
11
- var STARTUP_TIMEOUT_MS = 15e3;
12
- var RESTART_DELAY_MS = 2e3;
13
- var MAX_RESTART_ATTEMPTS = 3;
6
+ import { spawnSync } from "child_process";
7
+ import { existsSync } from "fs";
8
+ import { dirname, join } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import os from "os";
14
11
  var defaultLogger = {
15
12
  info: (m) => console.log(`[headroom] ${m}`),
16
13
  warn: (m) => console.warn(`[headroom] ${m}`),
@@ -18,249 +15,412 @@ var defaultLogger = {
18
15
  debug: () => {
19
16
  }
20
17
  };
18
+ var HEADROOM_MODULE_DISCOVERY_SNIPPET = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
21
19
  var ProxyManager = class {
22
20
  config;
23
21
  logger;
24
- process = null;
25
22
  proxyUrl = null;
26
- weStartedIt = false;
27
- restartCount = 0;
28
- healthInterval = null;
29
- disposed = false;
30
23
  constructor(config = {}, logger) {
31
24
  this.config = config;
32
25
  this.logger = logger ?? defaultLogger;
33
26
  }
34
27
  /**
35
- * Ensure a proxy is available. Returns the URL.
36
- *
37
- * 1. If proxyUrl is configured, use it
38
- * 2. Check if proxy is already running on default port
39
- * 3. If autoStart, spawn one
28
+ * Ensure a proxy is available. Returns the normalized URL origin.
40
29
  */
41
30
  async start() {
42
- if (this.config.proxyUrl) {
43
- const url = this.config.proxyUrl.replace(/\/+$/, "");
44
- if (await this.healthCheck(url)) {
31
+ const port = this.getProxyPort();
32
+ const rawExplicitUrl = typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0 ? normalizeAndValidateProxyUrl(this.config.proxyUrl) : null;
33
+ const explicitUrl = rawExplicitUrl ? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl : null;
34
+ const defaultCandidates = this.getDefaultProxyCandidates(port);
35
+ const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
36
+ const probeByUrl = /* @__PURE__ */ new Map();
37
+ for (const url of candidateUrls) {
38
+ const probe = await probeHeadroomProxy(url);
39
+ probeByUrl.set(url, probe);
40
+ if (probe.reachable && probe.isHeadroom) {
45
41
  this.proxyUrl = url;
46
- this.logger.info(`Connected to proxy at ${url}`);
42
+ this.logger.info(`Headroom proxy already running at ${url}`);
47
43
  return url;
48
44
  }
49
- throw new Error(`Headroom proxy not reachable at ${url}`);
50
- }
51
- const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
52
- if (await this.healthCheck(defaultUrl)) {
53
- this.proxyUrl = defaultUrl;
54
- this.logger.info(`Found running proxy at ${defaultUrl}`);
55
- this.startHealthMonitor();
56
- return defaultUrl;
57
45
  }
58
- if (this.config.autoStart !== false) {
59
- return this.spawnProxy();
46
+ if (explicitUrl) {
47
+ const explicitProbe = probeByUrl.get(explicitUrl);
48
+ if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
49
+ throw new Error(
50
+ `Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`
51
+ );
52
+ }
60
53
  }
61
- throw new Error(
62
- "Headroom proxy not running. Start with: headroom proxy --port 8787\nOr install: pip install 'headroom-ai[proxy]'"
63
- );
64
- }
65
- /**
66
- * Spawn the headroom proxy as a child process.
67
- */
68
- async spawnProxy() {
69
- const pythonPath = await this.findPython();
70
- if (!pythonPath) {
54
+ if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
71
55
  throw new Error(
72
- "Python not found. Install Python 3.10+ and run: pip install 'headroom-ai[proxy]'"
56
+ `Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`
73
57
  );
74
58
  }
75
- const installed = await this.checkHeadroomInstalled(pythonPath);
76
- if (!installed) {
59
+ if (this.config.autoStart === true) {
60
+ const startupUrl = explicitUrl ?? defaultCandidates[0];
61
+ const startupProbe = probeByUrl.get(startupUrl);
62
+ if (startupProbe?.reachable && !startupProbe.isHeadroom) {
63
+ throw new Error(
64
+ `Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`
65
+ );
66
+ }
67
+ this.logger.info(
68
+ `No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`
69
+ );
70
+ await this.startHeadroomProxy(startupUrl, port);
71
+ const startedProbe = await waitForHeadroomProxy(
72
+ startupUrl,
73
+ this.config.startupTimeoutMs ?? 2e4
74
+ );
75
+ if (startedProbe.reachable && startedProbe.isHeadroom) {
76
+ this.proxyUrl = startupUrl;
77
+ this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
78
+ return startupUrl;
79
+ }
77
80
  throw new Error(
78
- "headroom-ai Python package not found.\nInstall with: pip install 'headroom-ai[proxy]'"
81
+ `Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`
79
82
  );
80
83
  }
81
- const port = this.config.proxyPort ?? 0;
82
- const actualPort = port === 0 ? await this.findFreePort() : port;
83
- const url = `http://127.0.0.1:${actualPort}`;
84
- this.logger.info(`Starting proxy on port ${actualPort}...`);
85
- const logDir = join(homedir(), ".headroom", "logs");
86
- const logPath = join(logDir, "openclaw-proxy.log");
87
- let logStream = null;
88
- try {
89
- const { mkdirSync } = await import("fs");
90
- mkdirSync(logDir, { recursive: true });
91
- logStream = createWriteStream(logPath, { flags: "a" });
92
- } catch {
93
- }
94
- const proc = spawn(
95
- pythonPath,
96
- ["-m", "headroom.cli", "proxy", "--port", String(actualPort)],
97
- {
98
- env: { ...process.env, PYTHONIOENCODING: "utf-8" },
99
- stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"],
100
- detached: false
101
- }
102
- );
103
- if (logStream) {
104
- proc.stdout?.pipe(logStream);
105
- proc.stderr?.pipe(logStream);
106
- }
107
- proc.on("exit", (code) => {
108
- if (!this.disposed && this.weStartedIt) {
109
- this.logger.warn(`Proxy exited with code ${code}`);
110
- this.handleCrash();
111
- }
112
- });
113
- this.process = proc;
114
- this.weStartedIt = true;
115
- const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS);
116
- if (!healthy) {
117
- proc.kill();
118
- this.process = null;
84
+ if (explicitUrl) {
119
85
  throw new Error(
120
- `Proxy failed to start within ${STARTUP_TIMEOUT_MS / 1e3}s. Check ${logPath}`
86
+ `Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`
121
87
  );
122
88
  }
123
- this.proxyUrl = url;
124
- this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`);
125
- this.startHealthMonitor();
126
- return url;
89
+ throw new Error(
90
+ `Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). Set proxyUrl explicitly or enable autoStart.`
91
+ );
92
+ }
93
+ getProxyPort() {
94
+ const rawPort = this.config.proxyPort;
95
+ if (!Number.isInteger(rawPort) || rawPort === void 0) return 8787;
96
+ if (rawPort < 1 || rawPort > 65535) {
97
+ throw new Error("proxyPort must be an integer between 1 and 65535");
98
+ }
99
+ return rawPort;
100
+ }
101
+ getDefaultProxyCandidates(port) {
102
+ return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
127
103
  }
128
104
  /**
129
- * Stop the proxy if we started it.
105
+ * Stop manager state. Spawned proxy processes are detached and externally managed.
130
106
  */
131
107
  async stop() {
132
- this.disposed = true;
133
- if (this.healthInterval) {
134
- clearInterval(this.healthInterval);
135
- this.healthInterval = null;
136
- }
137
- if (this.process && this.weStartedIt) {
138
- this.logger.info("Stopping proxy...");
139
- this.process.kill("SIGTERM");
140
- await new Promise((resolve) => {
141
- const timeout = setTimeout(() => {
142
- this.process?.kill("SIGKILL");
143
- resolve();
144
- }, 3e3);
145
- this.process?.on("exit", () => {
146
- clearTimeout(timeout);
147
- resolve();
148
- });
149
- });
150
- this.process = null;
151
- }
108
+ this.proxyUrl = null;
152
109
  }
153
110
  getUrl() {
154
111
  return this.proxyUrl;
155
112
  }
156
113
  // --- Internal ---
157
- async healthCheck(url) {
158
- try {
159
- const resp = await fetch(`${url}/health`, {
160
- signal: AbortSignal.timeout(3e3)
161
- });
162
- return resp.ok;
163
- } catch {
164
- return false;
114
+ async startHeadroomProxy(proxyUrl, defaultPort) {
115
+ const parsed = new URL(proxyUrl);
116
+ const host = parsed.hostname;
117
+ const port = parsed.port || String(defaultPort);
118
+ const specs = this.buildLaunchSpecs(host, port);
119
+ const errors = [];
120
+ for (const spec of specs) {
121
+ if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
122
+ this.logger.debug(`Launcher unavailable: ${spec.label}`);
123
+ continue;
124
+ }
125
+ try {
126
+ const child = spawn(spec.command, spec.args, {
127
+ detached: true,
128
+ shell: spec.useShell === true,
129
+ stdio: "ignore"
130
+ });
131
+ child.unref();
132
+ this.logger.info(`Auto-start launcher selected: ${spec.label}`);
133
+ return;
134
+ } catch (error) {
135
+ errors.push(`${spec.label}: ${String(error)}`);
136
+ }
165
137
  }
138
+ throw new Error(
139
+ "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("; ")}` : "")
140
+ );
166
141
  }
167
- async waitForHealthy(url, timeoutMs) {
168
- const start = Date.now();
169
- while (Date.now() - start < timeoutMs) {
170
- if (await this.healthCheck(url)) return true;
171
- await new Promise((r) => setTimeout(r, 500));
142
+ buildLaunchSpecs(host, port) {
143
+ const commonArgs = ["proxy", "--host", host, "--port", port];
144
+ const retryMaxAttempts = this.config.retryMaxAttempts;
145
+ if (Number.isInteger(retryMaxAttempts)) {
146
+ commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
172
147
  }
173
- return false;
174
- }
175
- startHealthMonitor() {
176
- if (this.healthInterval) return;
177
- this.healthInterval = setInterval(async () => {
178
- if (this.proxyUrl && !await this.healthCheck(this.proxyUrl)) {
179
- this.logger.warn("Proxy health check failed");
180
- if (this.weStartedIt) this.handleCrash();
148
+ const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
149
+ if (Number.isInteger(connectTimeoutSeconds)) {
150
+ commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
151
+ }
152
+ const specs = [];
153
+ const configuredPython = this.getConfiguredPythonCommand();
154
+ if (configuredPython) {
155
+ specs.push({
156
+ label: `Configured Python: ${configuredPython} -m headroom.cli`,
157
+ command: configuredPython,
158
+ args: ["-m", "headroom.cli", ...commonArgs],
159
+ checkCommand: configuredPython,
160
+ checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET]
161
+ });
162
+ }
163
+ if (process.platform === "win32") {
164
+ const pyenvHeadroom = this.getPyenvResolvedHeadroom();
165
+ if (pyenvHeadroom) {
166
+ specs.push({
167
+ label: `pyenv: ${pyenvHeadroom}`,
168
+ command: pyenvHeadroom,
169
+ args: commonArgs,
170
+ checkCommand: pyenvHeadroom,
171
+ checkArgs: ["--version"],
172
+ useShell: false
173
+ });
174
+ }
175
+ }
176
+ specs.push({
177
+ label: "PATH: headroom",
178
+ command: "headroom",
179
+ args: commonArgs,
180
+ checkCommand: process.platform === "win32" ? "where.exe" : "sh",
181
+ checkArgs: process.platform === "win32" ? ["headroom"] : ["-c", "command -v headroom >/dev/null 2>&1"],
182
+ useShell: process.platform === "win32",
183
+ checkUseShell: false
184
+ });
185
+ const uvBin = join(
186
+ os.homedir(),
187
+ ".local",
188
+ "bin",
189
+ "headroom"
190
+ );
191
+ if (existsSync(uvBin)) {
192
+ specs.push({
193
+ label: `uv tool: ${uvBin}`,
194
+ command: uvBin,
195
+ args: commonArgs,
196
+ checkCommand: uvBin,
197
+ checkArgs: ["--version"]
198
+ });
199
+ }
200
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
201
+ const packageRoot = dirname(moduleDir);
202
+ const localBinDir = join(packageRoot, "node_modules", ".bin");
203
+ const localBins = process.platform === "win32" ? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")] : [join(localBinDir, "headroom")];
204
+ for (const localBin of localBins) {
205
+ if (!existsSync(localBin)) continue;
206
+ specs.push({
207
+ label: `Local npm: ${localBin}`,
208
+ command: localBin,
209
+ args: commonArgs,
210
+ checkCommand: localBin,
211
+ checkArgs: ["--version"],
212
+ useShell: process.platform === "win32"
213
+ });
214
+ }
215
+ const npmPrefix = this.getNpmGlobalPrefix();
216
+ if (npmPrefix) {
217
+ const globalBins = process.platform === "win32" ? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")] : [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];
218
+ for (const globalBin of globalBins) {
219
+ if (!existsSync(globalBin)) continue;
220
+ specs.push({
221
+ label: `Global npm: ${globalBin}`,
222
+ command: globalBin,
223
+ args: commonArgs,
224
+ checkCommand: globalBin,
225
+ checkArgs: ["--version"],
226
+ useShell: process.platform === "win32"
227
+ });
181
228
  }
182
- }, HEALTH_CHECK_INTERVAL_MS);
183
- }
184
- async handleCrash() {
185
- if (this.disposed) return;
186
- if (this.restartCount >= MAX_RESTART_ATTEMPTS) {
187
- this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`);
188
- return;
189
229
  }
190
- this.restartCount++;
191
- this.logger.info(`Restarting proxy (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
192
- await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
230
+ const pythonCommands = this.getPythonCommands();
231
+ for (const pyCmd of pythonCommands) {
232
+ if (configuredPython && pyCmd === configuredPython) continue;
233
+ specs.push({
234
+ label: `Python: ${pyCmd} -m headroom.cli`,
235
+ command: pyCmd,
236
+ args: ["-m", "headroom.cli", ...commonArgs],
237
+ checkCommand: pyCmd,
238
+ checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET]
239
+ });
240
+ }
241
+ return specs;
242
+ }
243
+ getConfiguredPythonCommand() {
244
+ const configured = typeof this.config.pythonPath === "string" ? this.config.pythonPath.trim() : "";
245
+ return configured.length > 0 ? configured : null;
246
+ }
247
+ getPyenvResolvedHeadroom() {
248
+ if (process.platform !== "win32") return null;
193
249
  try {
194
- await this.spawnProxy();
195
- } catch (e) {
196
- this.logger.error(`Restart failed: ${e}`);
250
+ const result = spawnSync("pyenv", ["which", "headroom"], {
251
+ encoding: "utf8",
252
+ stdio: ["ignore", "pipe", "ignore"],
253
+ timeout: 5e3
254
+ });
255
+ if (result.error || result.status !== 0) return null;
256
+ const resolved = (result.stdout ?? "").trim().split(/\r?\n/, 1)[0];
257
+ if (!resolved || !existsSync(resolved)) return null;
258
+ return resolved;
259
+ } catch {
260
+ return null;
197
261
  }
198
262
  }
199
- async findPython() {
200
- if (this.config.pythonPath) return this.config.pythonPath;
201
- for (const cmd of ["python3", "python"]) {
202
- try {
203
- const { execSync } = await import("child_process");
204
- const version = execSync(`${cmd} --version 2>&1`, { encoding: "utf-8" }).trim();
205
- if (version.includes("Python 3.")) return cmd;
206
- } catch {
207
- continue;
208
- }
263
+ getPythonCommands() {
264
+ const commands = [];
265
+ const configured = this.getConfiguredPythonCommand() ?? "";
266
+ if (configured.length > 0) {
267
+ commands.push(configured);
268
+ }
269
+ for (const fallback of ["python", "python3", "py"]) {
270
+ if (!commands.includes(fallback)) commands.push(fallback);
209
271
  }
210
- return null;
272
+ return commands;
211
273
  }
212
- async checkHeadroomInstalled(pythonPath) {
274
+ canExecute(command, args, useShell = false) {
213
275
  try {
214
- const { execSync } = await import("child_process");
215
- execSync(`${pythonPath} -c "import headroom"`, {
216
- encoding: "utf-8",
217
- stdio: "pipe"
276
+ const result = spawnSync(command, args, {
277
+ shell: useShell,
278
+ stdio: "ignore",
279
+ timeout: 5e3
218
280
  });
219
- return true;
281
+ if (result.error) return false;
282
+ return result.status === 0;
220
283
  } catch {
221
284
  return false;
222
285
  }
223
286
  }
224
- async findFreePort() {
225
- const { createServer } = await import("net");
226
- return new Promise((resolve, reject) => {
227
- const server = createServer();
228
- server.listen(0, () => {
229
- const addr = server.address();
230
- if (addr && typeof addr === "object") {
231
- const port = addr.port;
232
- server.close(() => resolve(port));
233
- } else {
234
- reject(new Error("Could not find free port"));
235
- }
287
+ getNpmGlobalPrefix() {
288
+ try {
289
+ const result = spawnSync("npm", ["prefix", "-g"], {
290
+ encoding: "utf8",
291
+ stdio: ["ignore", "pipe", "ignore"],
292
+ timeout: 5e3
236
293
  });
237
- });
294
+ if (result.error || result.status !== 0) return null;
295
+ const prefix = (result.stdout ?? "").trim();
296
+ return prefix.length > 0 ? prefix : null;
297
+ } catch {
298
+ return null;
299
+ }
238
300
  }
239
301
  };
302
+ function parseProxyUrl(proxyUrl) {
303
+ try {
304
+ return new URL(proxyUrl);
305
+ } catch {
306
+ throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
307
+ }
308
+ }
309
+ function normalizeAndValidateProxyUrl(proxyUrl) {
310
+ const parsed = parseProxyUrl(proxyUrl);
311
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
312
+ throw new Error("proxyUrl must use http:// or https://");
313
+ }
314
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
315
+ throw new Error("proxyUrl must not include a path, query, or hash");
316
+ }
317
+ return parsed.origin;
318
+ }
319
+ function isLocalProxyUrl(proxyUrl) {
320
+ try {
321
+ const parsed = new URL(proxyUrl);
322
+ return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+ function withDefaultPort(proxyUrl, defaultPort) {
328
+ const parsed = parseProxyUrl(proxyUrl);
329
+ if (!parsed.port) {
330
+ parsed.port = String(defaultPort);
331
+ }
332
+ return parsed.origin;
333
+ }
334
+ async function probeHeadroomProxy(proxyUrl) {
335
+ const origin = normalizeAndValidateProxyUrl(proxyUrl);
336
+ const probeEndpoint = async (path, options = {}) => {
337
+ try {
338
+ const response = await fetch(`${origin}${path}`, {
339
+ signal: AbortSignal.timeout(3e3)
340
+ });
341
+ const body = response.ok && options.readBody ? await response.text().catch(() => void 0) : void 0;
342
+ return { reachable: true, ok: response.ok, status: response.status, body };
343
+ } catch {
344
+ return { reachable: false, ok: false };
345
+ }
346
+ };
347
+ const ready = await probeEndpoint("/readyz");
348
+ const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
349
+ if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
350
+ return { reachable: true, isHeadroom: true };
351
+ }
352
+ const stats = await probeEndpoint("/stats", { readBody: true });
353
+ if (stats.ok && hasHeadroomStatsShape(stats.body)) {
354
+ return { reachable: true, isHeadroom: true };
355
+ }
356
+ const health = await probeEndpoint("/health");
357
+ const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
358
+ if (!anyReachable) {
359
+ return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
360
+ }
361
+ const reasons = [
362
+ ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
363
+ retrieveStats.reachable ? `retrieve stats HTTP ${retrieveStats.status}` : "retrieve stats endpoint unavailable",
364
+ stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
365
+ health.reachable ? `health HTTP ${health.status}` : "health check failed"
366
+ ];
367
+ return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
368
+ }
369
+ function hasHeadroomStatsShape(body) {
370
+ if (!body) {
371
+ return false;
372
+ }
373
+ try {
374
+ const parsed = JSON.parse(body);
375
+ 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"));
376
+ } catch {
377
+ return false;
378
+ }
379
+ }
380
+ async function waitForHeadroomProxy(proxyUrl, timeoutMs) {
381
+ const start = Date.now();
382
+ while (Date.now() - start < timeoutMs) {
383
+ const result = await probeHeadroomProxy(proxyUrl);
384
+ if (result.reachable && result.isHeadroom) {
385
+ return result;
386
+ }
387
+ await new Promise((resolve) => setTimeout(resolve, 500));
388
+ }
389
+ return probeHeadroomProxy(proxyUrl);
390
+ }
240
391
 
241
392
  // src/convert.ts
242
393
  function agentToOpenAI(messages) {
243
394
  const result = [];
244
395
  for (const msg of messages) {
245
- const role = msg.role;
396
+ const normalized = normalizeAgentMessage(msg);
397
+ const role = normalized.role;
398
+ const buildMeta = () => {
399
+ const meta = { ...normalized };
400
+ delete meta.role;
401
+ delete meta.content;
402
+ return meta;
403
+ };
246
404
  if (role === "system") {
247
405
  result.push({
248
406
  role: "system",
249
- content: typeof msg.content === "string" ? msg.content : extractText(msg.content)
407
+ content: typeof normalized.content === "string" ? normalized.content : extractText(normalized.content),
408
+ _headroomMeta: buildMeta()
250
409
  });
251
410
  continue;
252
411
  }
253
412
  if (role === "user") {
254
413
  result.push({
255
414
  role: "user",
256
- content: typeof msg.content === "string" ? msg.content : extractText(msg.content)
415
+ content: typeof normalized.content === "string" ? normalized.content : extractText(normalized.content),
416
+ _headroomMeta: buildMeta()
257
417
  });
258
418
  continue;
259
419
  }
260
420
  if (role === "assistant") {
261
- const content = msg.content;
421
+ const content = normalized.content;
262
422
  if (typeof content === "string") {
263
- result.push({ role: "assistant", content });
423
+ result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
264
424
  continue;
265
425
  }
266
426
  if (Array.isArray(content)) {
@@ -271,20 +431,22 @@ function agentToOpenAI(messages) {
271
431
  textParts.push(block);
272
432
  } else if (block.type === "text") {
273
433
  textParts.push(block.text);
274
- } else if (block.type === "tool_use") {
434
+ } else if (block.type === "tool_use" || block.type === "toolCall") {
435
+ const args = block.type === "toolCall" ? block.arguments : block.input;
275
436
  toolCalls.push({
276
437
  id: block.id,
277
438
  type: "function",
278
439
  function: {
279
440
  name: block.name,
280
- arguments: typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {})
441
+ arguments: typeof args === "string" ? args : JSON.stringify(args ?? {})
281
442
  }
282
443
  });
283
444
  }
284
445
  }
285
446
  const openaiMsg = {
286
447
  role: "assistant",
287
- content: textParts.length > 0 ? textParts.join("") : null
448
+ content: textParts.length > 0 ? textParts.join("") : null,
449
+ _headroomMeta: buildMeta()
288
450
  };
289
451
  if (toolCalls.length > 0) {
290
452
  openaiMsg.tool_calls = toolCalls;
@@ -294,17 +456,19 @@ function agentToOpenAI(messages) {
294
456
  continue;
295
457
  }
296
458
  if (role === "toolResult" || role === "tool_result") {
297
- const content = typeof msg.content === "string" ? msg.content : Array.isArray(msg.content) ? extractText(msg.content) : JSON.stringify(msg.content);
459
+ const content = typeof normalized.content === "string" ? normalized.content : Array.isArray(normalized.content) ? extractText(normalized.content) : JSON.stringify(normalized.content);
298
460
  result.push({
299
461
  role: "tool",
300
462
  content,
301
- tool_call_id: msg.tool_use_id ?? msg.toolCallId ?? msg.id ?? "unknown"
463
+ tool_call_id: normalized.toolCallId ?? normalized.tool_use_id ?? normalized.id ?? "unknown",
464
+ _headroomMeta: buildMeta()
302
465
  });
303
466
  continue;
304
467
  }
305
468
  result.push({
306
469
  role: "user",
307
- content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
470
+ content: typeof normalized.content === "string" ? normalized.content : JSON.stringify(normalized.content),
471
+ _headroomMeta: buildMeta()
308
472
  });
309
473
  }
310
474
  return result;
@@ -312,11 +476,13 @@ function agentToOpenAI(messages) {
312
476
  function openAIToAgent(messages) {
313
477
  const result = [];
314
478
  for (const msg of messages) {
479
+ const meta = msg._headroomMeta ?? {};
480
+ const timestamp = typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
315
481
  if (msg.role === "system") {
316
482
  result.push({
317
483
  role: "system",
318
484
  content: msg.content ?? "",
319
- timestamp: Date.now()
485
+ timestamp
320
486
  });
321
487
  continue;
322
488
  }
@@ -324,7 +490,7 @@ function openAIToAgent(messages) {
324
490
  result.push({
325
491
  role: "user",
326
492
  content: msg.content ?? "",
327
- timestamp: Date.now()
493
+ timestamp
328
494
  });
329
495
  continue;
330
496
  }
@@ -342,32 +508,55 @@ function openAIToAgent(messages) {
342
508
  input = tc.function.arguments ?? {};
343
509
  }
344
510
  blocks.push({
345
- type: "tool_use",
511
+ type: "toolCall",
346
512
  id: tc.id,
347
513
  name: tc.function.name,
348
- input
514
+ arguments: input
349
515
  });
350
516
  }
351
517
  }
352
518
  result.push({
519
+ ...meta,
353
520
  role: "assistant",
354
521
  content: blocks,
355
- timestamp: Date.now()
522
+ api: typeof meta.api === "string" ? meta.api : "headroom",
523
+ provider: typeof meta.provider === "string" ? meta.provider : "headroom",
524
+ model: typeof meta.model === "string" ? meta.model : "headroom",
525
+ usage: isRecord(meta.usage) ? meta.usage : {
526
+ input: 0,
527
+ output: 0,
528
+ cacheRead: 0,
529
+ cacheWrite: 0,
530
+ totalTokens: 0,
531
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
532
+ },
533
+ stopReason: typeof meta.stopReason === "string" ? meta.stopReason : "stop",
534
+ timestamp
356
535
  });
357
536
  continue;
358
537
  }
359
538
  if (msg.role === "tool") {
539
+ const textContent = typeof msg.content === "string" ? msg.content : msg.content == null ? "" : JSON.stringify(msg.content);
540
+ const toolCallId = msg.tool_call_id ?? "unknown";
360
541
  result.push({
542
+ ...meta,
361
543
  role: "toolResult",
362
- content: msg.content ?? "",
363
- tool_use_id: msg.tool_call_id ?? "unknown",
364
- timestamp: Date.now()
544
+ // OpenClaw transport layers expect toolResult content blocks, not a raw string.
545
+ content: [{ type: "text", text: textContent }],
546
+ toolCallId: typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
547
+ tool_use_id: typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
548
+ toolName: typeof meta.toolName === "string" ? meta.toolName : "headroom",
549
+ isError: typeof meta.isError === "boolean" ? meta.isError : false,
550
+ timestamp
365
551
  });
366
552
  continue;
367
553
  }
368
554
  }
369
555
  return result;
370
556
  }
557
+ function normalizeAgentMessages(messages) {
558
+ return messages.map((message) => normalizeAgentMessage(message));
559
+ }
371
560
  function extractText(content) {
372
561
  if (typeof content === "string") return content;
373
562
  if (!Array.isArray(content)) return JSON.stringify(content);
@@ -380,8 +569,115 @@ function extractText(content) {
380
569
  return "";
381
570
  }).filter(Boolean).join("\n");
382
571
  }
572
+ function normalizeAgentMessage(message) {
573
+ if (!isRecord(message)) return message;
574
+ if (message.role === "assistant") {
575
+ return normalizeAssistantMessage(message);
576
+ }
577
+ if (message.role === "toolResult" || message.role === "tool_result") {
578
+ return normalizeToolResultMessage(message);
579
+ }
580
+ return message;
581
+ }
582
+ function normalizeAssistantMessage(message) {
583
+ const normalizedContent = normalizeAssistantContent(message.content);
584
+ return {
585
+ ...message,
586
+ content: normalizedContent,
587
+ api: typeof message.api === "string" ? message.api : "headroom",
588
+ provider: typeof message.provider === "string" ? message.provider : "headroom",
589
+ model: typeof message.model === "string" ? message.model : "headroom",
590
+ usage: isRecord(message.usage) ? message.usage : {
591
+ input: 0,
592
+ output: 0,
593
+ cacheRead: 0,
594
+ cacheWrite: 0,
595
+ totalTokens: 0,
596
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
597
+ },
598
+ stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
599
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now()
600
+ };
601
+ }
602
+ function normalizeToolResultMessage(message) {
603
+ const normalizedContent = normalizeToolResultContent(message.content);
604
+ 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";
605
+ return {
606
+ ...message,
607
+ role: "toolResult",
608
+ content: normalizedContent,
609
+ toolCallId,
610
+ tool_use_id: typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
611
+ toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
612
+ isError: typeof message.isError === "boolean" ? message.isError : false,
613
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now()
614
+ };
615
+ }
616
+ function normalizeAssistantContent(content) {
617
+ if (Array.isArray(content)) {
618
+ return content.flatMap((block) => {
619
+ if (typeof block === "string") return [{ type: "text", text: block }];
620
+ if (!isRecord(block) || typeof block.type !== "string") return [];
621
+ if (block.type === "text" && typeof block.text === "string") return [block];
622
+ if (block.type === "thinking" && typeof block.thinking === "string") return [block];
623
+ if ((block.type === "toolCall" || block.type === "tool_use") && typeof block.name === "string") {
624
+ return [
625
+ {
626
+ type: "toolCall",
627
+ id: typeof block.id === "string" ? block.id : "unknown",
628
+ name: block.name,
629
+ arguments: "arguments" in block ? block.arguments : "input" in block ? block.input : {}
630
+ }
631
+ ];
632
+ }
633
+ return [];
634
+ });
635
+ }
636
+ if (typeof content === "string" && content.length > 0) {
637
+ return [{ type: "text", text: content }];
638
+ }
639
+ if (content == null) {
640
+ return [];
641
+ }
642
+ return [{ type: "text", text: JSON.stringify(content) }];
643
+ }
644
+ function normalizeToolResultContent(content) {
645
+ if (Array.isArray(content)) {
646
+ return content.flatMap((block) => {
647
+ if (typeof block === "string") return [{ type: "text", text: block }];
648
+ if (!isRecord(block) || typeof block.type !== "string") return [];
649
+ if (block.type === "text" && typeof block.text === "string") return [block];
650
+ if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
651
+ return [block];
652
+ }
653
+ if (block.type === "tool_result" && "content" in block) {
654
+ return normalizeToolResultContent(block.content);
655
+ }
656
+ return [];
657
+ });
658
+ }
659
+ if (typeof content === "string" && content.length > 0) {
660
+ return [{ type: "text", text: content }];
661
+ }
662
+ if (content == null) {
663
+ return [];
664
+ }
665
+ return [{ type: "text", text: JSON.stringify(content) }];
666
+ }
667
+ function isRecord(value) {
668
+ return typeof value === "object" && value !== null && !Array.isArray(value);
669
+ }
383
670
 
384
671
  // src/engine.ts
672
+ function withTimeout(promise, ms) {
673
+ let timerId;
674
+ const timer = new Promise((_, reject) => {
675
+ timerId = setTimeout(() => reject(new Error(`headroom compress() timed out after ${ms}ms`)), ms);
676
+ });
677
+ return Promise.race([promise, timer]).finally(() => {
678
+ if (timerId !== void 0) clearTimeout(timerId);
679
+ });
680
+ }
385
681
  var HeadroomContextEngine = class {
386
682
  info = {
387
683
  id: "headroom",
@@ -393,21 +689,19 @@ var HeadroomContextEngine = class {
393
689
  proxyUrl = null;
394
690
  config;
395
691
  logger;
692
+ proxyReadyListeners = /* @__PURE__ */ new Set();
693
+ proxyStartupPromise = null;
694
+ proxyStartupError = null;
396
695
  stats = {
397
696
  totalCompressions: 0,
398
697
  totalTokensSaved: 0,
399
698
  totalTokensBefore: 0,
400
699
  compactions: 0
401
700
  };
701
+ circuit = { errors: 0, openUntilMs: 0 };
402
702
  constructor(config = {}, logger) {
403
703
  this.config = config;
404
- this.logger = logger ?? {
405
- info: (m) => console.log(`[headroom] ${m}`),
406
- warn: (m) => console.warn(`[headroom] ${m}`),
407
- error: (m) => console.error(`[headroom] ${m}`),
408
- debug: () => {
409
- }
410
- };
704
+ this.logger = logger ?? defaultLogger;
411
705
  this.proxyManager = new ProxyManager(config, this.logger);
412
706
  }
413
707
  // === ContextEngine Lifecycle ===
@@ -415,14 +709,8 @@ var HeadroomContextEngine = class {
415
709
  if (this.config.enabled === false) {
416
710
  return { bootstrapped: false, reason: "disabled" };
417
711
  }
418
- try {
419
- this.proxyUrl = await this.proxyManager.start();
420
- this.logger.info(`Engine bootstrapped (proxy: ${this.proxyUrl})`);
421
- return { bootstrapped: true };
422
- } catch (error) {
423
- this.logger.error(`Bootstrap failed: ${error}`);
424
- return { bootstrapped: false, reason: String(error) };
425
- }
712
+ this.ensureProxyStarted();
713
+ return { bootstrapped: true, reason: "proxy startup scheduled" };
426
714
  }
427
715
  async ingest(params) {
428
716
  return { ingested: true };
@@ -437,20 +725,33 @@ var HeadroomContextEngine = class {
437
725
  */
438
726
  async assemble(params) {
439
727
  if (!this.proxyUrl || this.config.enabled === false) {
440
- return { messages: params.messages, estimatedTokens: 0 };
728
+ this.ensureProxyStarted();
729
+ return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
730
+ }
731
+ if (this.isCircuitOpen()) {
732
+ this.logger.warn("[headroom] Circuit open \u2014 using uncompressed messages");
733
+ return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
441
734
  }
442
735
  try {
443
736
  const openaiMessages = agentToOpenAI(params.messages);
444
- const result = await compress(openaiMessages, {
445
- model: params.model ?? "claude-sonnet-4-5",
446
- baseUrl: this.proxyUrl,
447
- fallback: true,
448
- tokenBudget: params.tokenBudget
449
- });
737
+ const result = await withTimeout(
738
+ compress(openaiMessages, {
739
+ model: params.model ?? "claude-sonnet-4-5",
740
+ baseUrl: this.proxyUrl,
741
+ fallback: true,
742
+ tokenBudget: params.tokenBudget
743
+ }),
744
+ this.config.requestTimeoutMs ?? 3e4
745
+ );
450
746
  if (!result.compressed || result.tokensSaved === 0) {
451
- return { messages: params.messages, estimatedTokens: result.tokensBefore };
747
+ this.resetCircuit();
748
+ return {
749
+ messages: normalizeAgentMessages(params.messages),
750
+ estimatedTokens: result.tokensBefore
751
+ };
452
752
  }
453
753
  const compressedAgentMessages = openAIToAgent(result.messages);
754
+ this.resetCircuit();
454
755
  this.stats.totalCompressions++;
455
756
  this.stats.totalTokensSaved += result.tokensSaved;
456
757
  this.stats.totalTokensBefore += result.tokensBefore;
@@ -464,7 +765,8 @@ var HeadroomContextEngine = class {
464
765
  };
465
766
  } catch (error) {
466
767
  this.logger.error(`Assemble failed: ${error}`);
467
- return { messages: params.messages, estimatedTokens: 0 };
768
+ this.tripCircuit(error);
769
+ return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
468
770
  }
469
771
  }
470
772
  /**
@@ -512,36 +814,201 @@ var HeadroomContextEngine = class {
512
814
  getProxyUrl() {
513
815
  return this.proxyUrl;
514
816
  }
817
+ getProxyStartupError() {
818
+ return this.proxyStartupError;
819
+ }
820
+ isCircuitOpen() {
821
+ const threshold = this.config.circuitBreakerThreshold ?? 3;
822
+ if (this.circuit.errors < threshold) return false;
823
+ if (Date.now() < this.circuit.openUntilMs) return true;
824
+ this.circuit = { errors: 0, openUntilMs: 0 };
825
+ return false;
826
+ }
827
+ tripCircuit(error) {
828
+ this.circuit.errors += 1;
829
+ const threshold = this.config.circuitBreakerThreshold ?? 3;
830
+ if (this.circuit.errors < threshold) return;
831
+ const cooldownMs = this.config.circuitBreakerCooldownMs ?? 6e4;
832
+ this.circuit.openUntilMs = Date.now() + cooldownMs;
833
+ this.logger.warn(
834
+ `[headroom] Circuit breaker opened after ${this.circuit.errors} errors (last: ${String(error)}); bypassing compression for ${cooldownMs}ms`
835
+ );
836
+ }
837
+ resetCircuit() {
838
+ this.circuit = { errors: 0, openUntilMs: 0 };
839
+ }
840
+ ensureProxyStarted() {
841
+ if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
842
+ return;
843
+ }
844
+ this.proxyStartupError = null;
845
+ this.proxyStartupPromise = this.proxyManager.start().then(async (proxyUrl) => {
846
+ this.proxyUrl = proxyUrl;
847
+ this.proxyStartupError = null;
848
+ await this.notifyProxyReady(proxyUrl);
849
+ this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
850
+ return proxyUrl;
851
+ }).catch((error) => {
852
+ this.proxyStartupError = error;
853
+ this.logger.warn(`Headroom proxy unavailable: ${error}`);
854
+ throw error;
855
+ }).finally(() => {
856
+ this.proxyStartupPromise = null;
857
+ });
858
+ void this.proxyStartupPromise.catch(() => {
859
+ });
860
+ }
861
+ onProxyReady(listener) {
862
+ this.proxyReadyListeners.add(listener);
863
+ return () => {
864
+ this.proxyReadyListeners.delete(listener);
865
+ };
866
+ }
867
+ async ensureProxyUrl() {
868
+ if (this.proxyUrl) {
869
+ return this.proxyUrl;
870
+ }
871
+ this.ensureProxyStarted();
872
+ if (!this.proxyStartupPromise) {
873
+ throw new Error("Headroom proxy startup is disabled");
874
+ }
875
+ return this.proxyStartupPromise;
876
+ }
877
+ async notifyProxyReady(proxyUrl) {
878
+ for (const listener of this.proxyReadyListeners) {
879
+ try {
880
+ await listener(proxyUrl);
881
+ } catch (error) {
882
+ this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
883
+ }
884
+ }
885
+ }
515
886
  };
516
887
 
888
+ // src/gateway-config.ts
889
+ var DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"];
890
+ var DEFAULT_PROVIDER_BASE_URLS = {
891
+ "openai-codex": "https://chatgpt.com/backend-api"
892
+ };
893
+ var GATEWAY_PROVIDER_ID_ALIASES = {
894
+ codex: "openai-codex",
895
+ claude: "anthropic",
896
+ copilot: "github-copilot",
897
+ gemini: "google"
898
+ };
899
+ var EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = /* @__PURE__ */ new Set(["github-copilot"]);
900
+ function resolveGatewayProviderIds(config) {
901
+ const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
902
+ if (configuredProviderIds.length > 0) {
903
+ return configuredProviderIds;
904
+ }
905
+ if (config?.routeCodexViaProxy === false) {
906
+ return [];
907
+ }
908
+ return [...DEFAULT_GATEWAY_PROVIDER_IDS];
909
+ }
910
+ function normalizeGatewayProviderIds(value) {
911
+ if (!Array.isArray(value)) {
912
+ return [];
913
+ }
914
+ const seen = /* @__PURE__ */ new Set();
915
+ const normalized = [];
916
+ for (const entry of value) {
917
+ if (typeof entry !== "string") {
918
+ continue;
919
+ }
920
+ const rawProviderId = entry.trim();
921
+ const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
922
+ if (!providerId || seen.has(providerId)) {
923
+ continue;
924
+ }
925
+ seen.add(providerId);
926
+ normalized.push(providerId);
927
+ }
928
+ return normalized;
929
+ }
930
+ function applyGatewayProviderBaseUrls(cfg, proxyUrl, providerIds) {
931
+ const next = structuredClone(cfg ?? {});
932
+ const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
933
+ return { changed, config: next };
934
+ }
935
+ function applyGatewayProviderBaseUrlsInPlace(cfg, proxyUrl, providerIds) {
936
+ if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
937
+ return false;
938
+ }
939
+ const models = cfg.models ??= {};
940
+ const providers = models.providers ??= {};
941
+ let changed = false;
942
+ for (const providerId of providerIds) {
943
+ const currentValue = providers[providerId];
944
+ const currentConfig = currentValue && typeof currentValue === "object" && !Array.isArray(currentValue) ? currentValue : {};
945
+ const nextConfig = { ...currentConfig };
946
+ const currentBaseUrl = typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0 ? nextConfig.baseUrl : void 0;
947
+ const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
948
+ if (!currentBaseUrl && !defaultBaseUrl && EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)) {
949
+ continue;
950
+ }
951
+ const nextBaseUrl = routeBaseUrlThroughProxy({
952
+ providerId,
953
+ proxyUrl,
954
+ currentBaseUrl
955
+ });
956
+ if (!Array.isArray(nextConfig.models)) {
957
+ nextConfig.models = [];
958
+ changed = true;
959
+ }
960
+ if (nextConfig.baseUrl === nextBaseUrl) {
961
+ providers[providerId] = nextConfig;
962
+ continue;
963
+ }
964
+ nextConfig.baseUrl = nextBaseUrl;
965
+ providers[providerId] = nextConfig;
966
+ changed = true;
967
+ }
968
+ return changed;
969
+ }
970
+ function routeBaseUrlThroughProxy(params) {
971
+ const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
972
+ if (!upstreamBaseUrl) {
973
+ return params.proxyUrl;
974
+ }
975
+ try {
976
+ const proxy = new URL(params.proxyUrl);
977
+ const upstream = new URL(upstreamBaseUrl);
978
+ proxy.pathname = upstream.pathname;
979
+ proxy.search = upstream.search;
980
+ proxy.hash = "";
981
+ return proxy.toString().replace(/\/$/, "");
982
+ } catch {
983
+ return params.proxyUrl;
984
+ }
985
+ }
986
+
517
987
  // src/tools/headroom-retrieve.ts
518
988
  function createHeadroomRetrieveTool(config) {
989
+ const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
519
990
  return {
520
991
  name: "headroom_retrieve",
521
- 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). Optionally pass a query to search within the original content.",
992
+ 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.",
522
993
  parameters: {
523
994
  type: "object",
524
995
  properties: {
525
996
  hash: {
526
997
  type: "string",
527
998
  description: "The 24-character hex hash from the compression marker"
528
- },
529
- query: {
530
- type: "string",
531
- description: "Optional search query to filter results within the original content"
532
999
  }
533
1000
  },
534
1001
  required: ["hash"]
535
1002
  },
536
1003
  execute: async (args) => {
537
- const { hash, query } = args;
1004
+ const { hash } = args;
538
1005
  if (!/^[a-f0-9]{24}$/i.test(hash)) {
539
1006
  return JSON.stringify({
540
1007
  error: "Invalid hash format. Expected 24 hex characters."
541
1008
  });
542
1009
  }
543
1010
  try {
544
- const url = query ? `${config.proxyUrl}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}` : `${config.proxyUrl}/v1/retrieve/${hash}`;
1011
+ const url = `${proxyOrigin}/v1/retrieve/${hash}`;
545
1012
  const resp = await fetch(url, {
546
1013
  signal: AbortSignal.timeout(1e4)
547
1014
  });
@@ -557,7 +1024,7 @@ function createHeadroomRetrieveTool(config) {
557
1024
  } catch (error) {
558
1025
  return JSON.stringify({
559
1026
  error: `Retrieval failed: ${error}`,
560
- hint: "The compressed content may have expired (default TTL: 5 minutes)"
1027
+ hint: "The compressed content may have expired (default TTL: 30 minutes)"
561
1028
  });
562
1029
  }
563
1030
  }
@@ -565,29 +1032,112 @@ function createHeadroomRetrieveTool(config) {
565
1032
  }
566
1033
 
567
1034
  // src/plugin/index.ts
568
- function headroomPlugin(api) {
1035
+ var plugin_default = {
1036
+ register: registerHeadroomPlugin
1037
+ };
1038
+ function registerHeadroomPlugin(api) {
569
1039
  const config = api.config?.plugins?.entries?.headroom?.config ?? {};
570
1040
  const logger = api.logger ?? console;
571
- const engine = new HeadroomContextEngine(config, {
1041
+ const rawProxyUrl = config.proxyUrl;
1042
+ const proxyUrl = typeof rawProxyUrl === "string" && rawProxyUrl.trim().length > 0 ? normalizeAndValidateProxyUrl(rawProxyUrl) : void 0;
1043
+ const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
572
1044
  info: (m) => logger.info(m),
573
1045
  warn: (m) => logger.warn(m),
574
1046
  error: (m) => logger.error(m),
575
1047
  debug: (m) => logger.debug?.(m)
576
1048
  });
1049
+ const gatewayProviderIds = resolveGatewayProviderIds(config);
1050
+ let validatedConfiguredProxyUrl = null;
1051
+ let configuredProxyProbePromise = null;
1052
+ const applyGatewayRouting = async (activeProxyUrl) => {
1053
+ if (gatewayProviderIds.length === 0) {
1054
+ return;
1055
+ }
1056
+ try {
1057
+ const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
1058
+ if (changed) {
1059
+ logger.info(
1060
+ `[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`
1061
+ );
1062
+ } else {
1063
+ logger.info(
1064
+ `[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`
1065
+ );
1066
+ }
1067
+ } catch (error) {
1068
+ logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
1069
+ }
1070
+ };
1071
+ const getConfiguredRoutingProxyUrl = async () => {
1072
+ if (!proxyUrl) {
1073
+ return null;
1074
+ }
1075
+ if (validatedConfiguredProxyUrl === proxyUrl) {
1076
+ return validatedConfiguredProxyUrl;
1077
+ }
1078
+ if (!configuredProxyProbePromise) {
1079
+ configuredProxyProbePromise = probeHeadroomProxy(proxyUrl).then((probe) => {
1080
+ if (probe.reachable && probe.isHeadroom) {
1081
+ validatedConfiguredProxyUrl = proxyUrl;
1082
+ return proxyUrl;
1083
+ }
1084
+ logger.warn(
1085
+ `[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` + (probe.reason ? ` (${probe.reason})` : "")
1086
+ );
1087
+ return null;
1088
+ }).catch((error) => {
1089
+ logger.warn(
1090
+ `[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`
1091
+ );
1092
+ return null;
1093
+ }).finally(() => {
1094
+ configuredProxyProbePromise = null;
1095
+ });
1096
+ }
1097
+ return configuredProxyProbePromise;
1098
+ };
1099
+ const ensureGatewayRouting = async () => {
1100
+ if (gatewayProviderIds.length === 0) {
1101
+ return;
1102
+ }
1103
+ const activeProxyUrl = engine.getProxyUrl() ?? await getConfiguredRoutingProxyUrl();
1104
+ if (!activeProxyUrl) {
1105
+ logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
1106
+ return;
1107
+ }
1108
+ await applyGatewayRouting(activeProxyUrl);
1109
+ };
1110
+ engine.onProxyReady(async (activeProxyUrl) => {
1111
+ await applyGatewayRouting(activeProxyUrl);
1112
+ });
577
1113
  api.registerContextEngine("headroom", () => engine);
578
1114
  api.registerTool((ctx) => {
579
- const proxyUrl = engine.getProxyUrl();
580
- if (!proxyUrl) return null;
581
- return createHeadroomRetrieveTool({ proxyUrl });
1115
+ const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
1116
+ if (!activeProxyUrl) return null;
1117
+ return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
1118
+ }, { names: ["headroom_retrieve"] });
1119
+ api.on("gateway_start", async () => {
1120
+ await ensureGatewayRouting();
582
1121
  });
1122
+ void ensureGatewayRouting();
583
1123
  logger.info("[headroom] Plugin registered");
584
1124
  }
585
1125
  export {
1126
+ DEFAULT_GATEWAY_PROVIDER_IDS,
586
1127
  HeadroomContextEngine,
587
1128
  ProxyManager,
588
1129
  agentToOpenAI,
1130
+ applyGatewayProviderBaseUrls,
1131
+ applyGatewayProviderBaseUrlsInPlace,
589
1132
  createHeadroomRetrieveTool,
590
- headroomPlugin as default,
591
- openAIToAgent
1133
+ plugin_default as default,
1134
+ defaultLogger,
1135
+ isLocalProxyUrl,
1136
+ normalizeAgentMessages,
1137
+ normalizeAndValidateProxyUrl,
1138
+ openAIToAgent,
1139
+ probeHeadroomProxy,
1140
+ registerHeadroomPlugin,
1141
+ resolveGatewayProviderIds
592
1142
  };
593
1143
  //# sourceMappingURL=index.js.map