@raquezha/noheadroom 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -1,28 +1,21 @@
1
1
  # noheadroom 🗜
2
2
 
3
- > **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroom-ai/headroom).
3
+ > **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroomlabs-ai/headroom).
4
4
 
5
5
  `noheadroom` sits between Pi and your LLM, using Headroom to shrink massive tool outputs/results and logs before they become model input. Headroom itself is a general prompt/context compression engine that can transform broader request material. `noheadroom` deliberately applies a stricter Pi policy: user prompts and assistant messages may be sent as context for the compression request, but Pi only accepts mutations to `toolResult` content. User chat, assistant text, tool-call metadata, and tool IDs remain unchanged in real Pi history. Save tokens, keep more history, and prevent context-overflow in long sessions.
6
6
 
7
7
  ## 🚀 Why noheadroom?
8
8
 
9
- Upstream Headroom protects common agent tool names like `read` and `bash` by default. In a standard Pi workflow, this means large file reads often bypass compression entirely.
10
-
11
- `noheadroom` adapts the compression payload to bypass these exclusions while **fully preserving your Pi session metadata**.
12
-
13
- | Setup | Tool Name | Headroom Action | Savings |
14
- |---|---|---|---|
15
- | Vanilla | `read` | `excluded_tool` | 0% |
16
- | **noheadroom** | `read` | `smart_crusher` | **60-90%** |
9
+ Headroom intentionally protects file reads: changing source text can break exact-match edits. `noheadroom` keeps those reads raw and compresses eligible large text-only tool results (for example, repetitive logs). It never changes the stored Pi session transcript.
17
10
 
18
11
  ## ✨ Features
19
12
 
20
13
  - **Headroom Bridge, Pi Policy**: Headroom can optimize broad prompt/context payloads; `noheadroom` intentionally narrows what gets applied back to Pi so only `toolResult` content mutates.
21
- - **Adaptive Payload Sanitization**: renames tool calls during compression to ensure Headroom actually shrinks them.
22
- - **Strict Candidate Isolation**: limits applied mutations strictly to `toolResult` messages, safely ignoring upstream proxy mangling of user/assistant history to prevent false-positive guard skips.
23
- - **Turn-by-Turn Loop Prevention**: caches eligible `toolResult` candidate fingerprints using tool identity, content shape, length, and stable content hashes to block useless proxy retries across new conversational turns while still retrying when actual tool output changes.
14
+ - **Tool Fidelity**: preserves tool names and arguments so Headroom's protected-tool policy remains effective.
15
+ - **Strict Candidate Isolation**: applies only eligible text-only, non-error `toolResult` changes; rejects retrieval markers without a registered retrieval tool.
16
+ - **Request-local Replay**: reuses validated compression for the same tool call and unchanged result on later model requests without re-calling the proxy. A new tool call with the same output remains raw to avoid re-read loops.
24
17
  - **Pi-Native Metadata Preservation**: original tool IDs and names are never modified in your real session.
25
- - **Deep Visibility**: compression results appear in your terminal, the Pi footer, and as persistent entries in your session history.
18
+ - **Visibility**: session-local compression estimates appear in the terminal and Pi footer; proxy `/stats` does not count extension `/v1/compress` requests as inference savings.
26
19
  - **Docker-First Architecture**: designed to work seamlessly with a local containerized backend.
27
20
  - **Local-First Privacy**: by default, context never leaves your machine.
28
21
 
package/dist/bridge.d.ts CHANGED
@@ -5,8 +5,7 @@ type AnyMessage = AgentMessage & {
5
5
  timestamp?: number;
6
6
  };
7
7
  export declare function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload;
8
- export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], _options: ApplyCompressionOptions): ApplyCompressionResult;
8
+ export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], options: ApplyCompressionOptions): ApplyCompressionResult;
9
9
  export declare function convertMessage(message: AnyMessage): OpenAIMessage | undefined;
10
10
  export declare function extractOpenAIText(message: OpenAIMessage): string;
11
- export declare function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[];
12
11
  export {};
package/dist/bridge.js CHANGED
@@ -1,4 +1,7 @@
1
1
  const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
2
+ // File reads supply exact edit anchors; directives and recovered evidence must stay raw.
3
+ const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
4
+ const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
2
5
  export function buildCompressionPayload(messages, minMessageChars) {
3
6
  const mappings = [];
4
7
  let candidateCount = 0;
@@ -10,13 +13,19 @@ export function buildCompressionPayload(messages, minMessageChars) {
10
13
  const originalText = extractOpenAIText(converted);
11
14
  // Mark candidates for compression.
12
15
  // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
13
- let applyTo = source.role === "toolResult" ? "toolResult" : null;
16
+ const textOnly = typeof source.content === "string" ||
17
+ (Array.isArray(source.content) && source.content.every(isTextContent));
18
+ let applyTo = source.role === "toolResult" &&
19
+ textOnly && originalText.length >= minMessageChars &&
20
+ !("isError" in source && source.isError) &&
21
+ !PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
22
+ ? "toolResult" : null;
14
23
  // Headroom Bypass Rules (Android Hat)
15
24
  // We never want to compress `android layout` JSON dumps or critical adb dumps.
16
25
  if (source.role === "toolResult" && originalText.trim().startsWith("[") && originalText.includes('"resource-id"')) {
17
26
  applyTo = null;
18
27
  }
19
- if (applyTo && originalText.length >= minMessageChars)
28
+ if (applyTo)
20
29
  candidateCount++;
21
30
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
22
31
  }
@@ -26,38 +35,59 @@ export function buildCompressionPayload(messages, minMessageChars) {
26
35
  candidateCount,
27
36
  };
28
37
  }
29
- export function applyCompressionResult(originalMessages, mappings, compressedMessages, _options) {
30
- if (compressedMessages.length !== mappings.length) {
38
+ export function applyCompressionResult(originalMessages, mappings, compressedMessages, options) {
39
+ if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
31
40
  return { ok: false, reason: "message-count-changed" };
32
41
  }
33
42
  const nextMessages = structuredClone(originalMessages);
34
43
  let appliedMessages = 0;
44
+ let appliedTokensBefore = 0;
45
+ let appliedTokensAfter = 0;
35
46
  for (let index = 0; index < mappings.length; index++) {
36
47
  const mapping = mappings[index];
37
48
  const compressed = compressedMessages[index];
38
49
  // We only validate and apply changes to explicit candidates.
39
50
  // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
40
51
  // but we simply ignore those changes and keep the original Pi message intact.
41
- if (!mapping.applyTo)
52
+ if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars)
42
53
  continue;
43
54
  const validation = validateAlignedMessage(mapping.message, compressed);
44
55
  if (!validation.ok)
45
56
  return validation;
46
- let nextText = extractOpenAIText(compressed);
57
+ const nextText = extractOpenAIText(compressed);
47
58
  if (nextText === mapping.originalText)
48
59
  continue;
49
- // Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
50
- nextText = naturalizeHeadroomMarkers(nextText);
60
+ // No retrieval executor is registered. Hiding a hash does not recover evidence.
61
+ if (CCR_MARKER.test(nextText))
62
+ return { ok: false, reason: "unrecoverable-ccr-marker" };
63
+ if (!nextText.trim())
64
+ return { ok: false, reason: "empty-compressed-content" };
65
+ if (estimateTokens(nextText) >= estimateTokens(mapping.originalText))
66
+ continue;
51
67
  const target = nextMessages[mapping.sourceIndex];
52
68
  if (!hasContent(target) || !replaceTextContent(target, nextText)) {
53
69
  return { ok: false, reason: "target-content-unreplaceable" };
54
70
  }
71
+ appliedTokensBefore += estimateTokens(mapping.originalText);
72
+ appliedTokensAfter += estimateTokens(nextText);
55
73
  appliedMessages++;
56
74
  }
57
75
  if (appliedMessages === 0) {
58
76
  return { ok: false, reason: "no-applicable-message-changed" };
59
77
  }
60
- return { ok: true, messages: nextMessages, appliedMessages };
78
+ const appliedTokensSaved = Math.max(0, appliedTokensBefore - appliedTokensAfter);
79
+ if (appliedTokensSaved === 0) {
80
+ return { ok: false, reason: "no-estimated-token-savings" };
81
+ }
82
+ return {
83
+ ok: true,
84
+ messages: nextMessages,
85
+ appliedMessages,
86
+ appliedTokensBefore,
87
+ appliedTokensAfter,
88
+ appliedTokensSaved,
89
+ appliedCompressionRatio: appliedTokensAfter / appliedTokensBefore,
90
+ };
61
91
  }
62
92
  export function convertMessage(message) {
63
93
  if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role))
@@ -126,14 +156,16 @@ function convertToolCall(toolCall) {
126
156
  id: toolCall.id,
127
157
  type: "function",
128
158
  function: {
129
- // Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
130
- // Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
131
- name: "pi_tool_result",
132
- arguments: JSON.stringify({ originalToolName: toolCall.name }),
159
+ // Preserve upstream tool protections as well as routing context.
160
+ name: toolCall.name,
161
+ arguments: JSON.stringify(toolCall.arguments),
133
162
  },
134
163
  };
135
164
  }
136
165
  function validateAlignedMessage(original, compressed) {
166
+ if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
167
+ return { ok: false, reason: "invalid-compressed-message" };
168
+ }
137
169
  if (original.role !== compressed.role) {
138
170
  return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
139
171
  }
@@ -221,24 +253,7 @@ function readStringProperty(value, key) {
221
253
  function isRecord(value) {
222
254
  return typeof value === "object" && value !== null;
223
255
  }
224
- function naturalizeHeadroomMarkers(text) {
225
- // Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
226
- // into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
227
- return text.replace(/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi, "[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]");
228
- }
229
- export function injectCompressionAwareness(messages) {
230
- // If a system message already exists at the top, we append the hint to it.
231
- // Otherwise, we inject a new system message at index 0.
232
- const result = [...messages];
233
- const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
234
- const first = result[0];
235
- if (first && first.role === "system" && typeof first.content === "string") {
236
- if (!first.content.includes("automatically compressed by Headroom")) {
237
- result[0] = { ...first, content: `${first.content}\n\n${hintText}` };
238
- }
239
- }
240
- else {
241
- result.unshift({ role: "system", content: hintText });
242
- }
243
- return result;
256
+ function estimateTokens(text) {
257
+ // ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
258
+ return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
244
259
  }
package/dist/client.js CHANGED
@@ -8,14 +8,13 @@ export class HeadroomHttpClient {
8
8
  async health(signal) {
9
9
  try {
10
10
  const response = await fetch(`${this.baseUrl}/health`, {
11
+ redirect: "error",
11
12
  signal: buildSignal(this.timeoutMs, signal),
12
13
  });
13
14
  if (!response.ok)
14
15
  return false;
15
16
  const body = await readJsonObject(response);
16
- if (!body)
17
- return true;
18
- return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
17
+ return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
19
18
  }
20
19
  catch {
21
20
  return false;
@@ -24,12 +23,16 @@ export class HeadroomHttpClient {
24
23
  async probe(signal) {
25
24
  try {
26
25
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
26
+ redirect: "error",
27
27
  method: "POST",
28
28
  headers: { "Content-Type": "application/json" },
29
29
  body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
30
30
  signal: buildSignal(5_000, signal),
31
31
  });
32
- return response.ok;
32
+ if (!response.ok)
33
+ return false;
34
+ validateCompressionResponse(await response.json());
35
+ return true;
33
36
  }
34
37
  catch {
35
38
  return false;
@@ -37,6 +40,7 @@ export class HeadroomHttpClient {
37
40
  }
38
41
  async stats(signal) {
39
42
  const response = await fetch(`${this.baseUrl}/stats`, {
43
+ redirect: "error",
40
44
  signal: buildSignal(this.timeoutMs, signal),
41
45
  });
42
46
  if (!response.ok) {
@@ -46,19 +50,25 @@ export class HeadroomHttpClient {
46
50
  }
47
51
  async compress(messages, model, signal) {
48
52
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
53
+ redirect: "error",
49
54
  method: "POST",
50
55
  headers: {
51
56
  "Content-Type": "application/json",
52
57
  "X-Headroom-Stack": "pi-extension",
53
58
  },
54
- body: JSON.stringify({ messages, model: model || "gpt-4o" }),
59
+ body: JSON.stringify({
60
+ messages,
61
+ model: model || "gpt-4o",
62
+ // 0.39.1's default is marker-free and inherits the backend's --lossless policy.
63
+ // lossy_inline would override that policy and enable lossy SmartCrusher.
64
+ }),
55
65
  signal: buildSignal(this.timeoutMs, signal),
56
66
  });
57
67
  if (!response.ok) {
58
68
  const message = await readErrorMessage(response);
59
69
  throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
60
70
  }
61
- const payload = (await response.json());
71
+ const payload = validateCompressionResponse(await response.json());
62
72
  return {
63
73
  messages: payload.messages,
64
74
  tokensBefore: payload.tokens_before,
@@ -71,6 +81,17 @@ export class HeadroomHttpClient {
71
81
  };
72
82
  }
73
83
  }
84
+ function validateCompressionResponse(value) {
85
+ const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
86
+ const stringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === "string");
87
+ if (!isRecord(value) || !Array.isArray(value.messages) ||
88
+ !metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
89
+ (value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
90
+ (value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || value.ccr_hashes.length > 0))) {
91
+ throw new Error("Invalid or retrieval-dependent Headroom compression response");
92
+ }
93
+ return value;
94
+ }
74
95
  async function readJsonObject(response) {
75
96
  try {
76
97
  const body = (await response.json());
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
5
- const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
5
+ const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
6
6
  const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
7
7
  const DEFAULT_TIMEOUT_MS = 30_000;
8
8
  export const HEADROOM_SETTINGS_DIR = path.join(os.homedir(), ".pi", "agent", "headroom");
@@ -48,7 +48,8 @@ export function loadHeadroomConfig(env = process.env, settings = env === process
48
48
  export function isLocalHeadroomUrl(rawUrl) {
49
49
  try {
50
50
  const url = new URL(rawUrl);
51
- return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
51
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
52
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
52
53
  }
53
54
  catch {
54
55
  return false;
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ interface HeadroomRuntimeState {
14
14
  lastGuardSkipCandidateFingerprint: string | null;
15
15
  seenCandidateContentFingerprints?: Set<string>;
16
16
  seenCandidateContentOrder?: string[];
17
+ compressedCandidates?: Map<string, string>;
17
18
  lastCompressionTime: number;
18
19
  stats: HeadroomStats;
19
20
  }
package/dist/index.js CHANGED
@@ -17,6 +17,14 @@ export default function headroomExtension(pi) {
17
17
  registerCompressionRenderer(pi);
18
18
  const runtime = createRuntime(pi);
19
19
  pi.on("session_start", (_event, ctx) => {
20
+ // A context transform is request-local; cached substitutions must not cross sessions.
21
+ runtime.state.compressedCandidates = new Map();
22
+ runtime.state.seenCandidateContentFingerprints = new Set();
23
+ runtime.state.seenCandidateContentOrder = [];
24
+ runtime.state.lastInputFingerprint = null;
25
+ runtime.state.lastOutputFingerprint = null;
26
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
27
+ runtime.state.lastCompressionTime = 0;
20
28
  if (isRemoteBlocked(runtime.config)) {
21
29
  runtime.refreshStatus(ctx);
22
30
  emitNotraceTelemetry(runtime);
@@ -79,6 +87,7 @@ function createRuntime(pi) {
79
87
  lastGuardSkipCandidateFingerprint: null,
80
88
  seenCandidateContentFingerprints: new Set(),
81
89
  seenCandidateContentOrder: [],
90
+ compressedCandidates: new Map(),
82
91
  lastCompressionTime: 0,
83
92
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
84
93
  };
@@ -207,38 +216,42 @@ function registerCompressionRenderer(pi) {
207
216
  async function handleContextCompression(runtime, event, ctx) {
208
217
  if (runtime.state.processing)
209
218
  return undefined;
210
- // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
211
- const now = Date.now();
212
- if (now - runtime.state.lastCompressionTime < 3000) {
213
- return undefined;
214
- }
215
- // Content-based guards to prevent infinite recursion
216
- const inputFingerprint = generateFingerprint(event.messages);
217
- // 1. If this input matches our previous output, we already compressed it. Stop.
218
- if (runtime.state.lastOutputFingerprint === inputFingerprint) {
219
- return undefined;
220
- }
221
- // 2. If this input matches our previous input, it didn't change. Stop.
222
- if (runtime.state.lastInputFingerprint === inputFingerprint) {
223
- return undefined;
224
- }
219
+ let replayed;
220
+ const bypassUncompressed = () => {
221
+ if (runtime.state.stats.last) {
222
+ runtime.state.stats.last = undefined;
223
+ runtime.refreshStatus(ctx);
224
+ }
225
+ return replayed ? { messages: replayed } : undefined;
226
+ };
225
227
  if (shouldSkipBeforePayload(runtime, ctx))
226
- return undefined;
228
+ return bypassUncompressed();
227
229
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
230
+ replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
228
231
  if (payload.candidateCount === 0)
232
+ return bypassUncompressed();
233
+ // Pi's context transform is request-local; reuse validated text on later turns.
234
+ const now = Date.now();
235
+ if (now - runtime.state.lastCompressionTime < 3000)
236
+ return bypassUncompressed();
237
+ const inputFingerprint = generateFingerprint(event.messages);
238
+ if (runtime.state.lastOutputFingerprint === inputFingerprint)
229
239
  return undefined;
240
+ if (runtime.state.lastInputFingerprint === inputFingerprint)
241
+ return bypassUncompressed();
230
242
  // 3. If eligible candidates haven't changed since the last skip/no-savings result,
231
243
  // don't spend another proxy call just because surrounding conversation changed.
232
244
  const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
233
245
  if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
234
- return undefined;
246
+ return bypassUncompressed();
235
247
  }
236
248
  if (allCandidateContentSeen(runtime.state, payload)) {
237
- return undefined;
249
+ return bypassUncompressed();
238
250
  }
251
+ ignoreSeenCandidateContent(runtime.state, payload);
239
252
  if (runtime.state.proxyOnline !== true) {
240
253
  void ensureProxyInBackground(runtime, ctx);
241
- return undefined;
254
+ return bypassUncompressed();
242
255
  }
243
256
  runtime.state.processing = true;
244
257
  runtime.state.lastCompressionTime = now;
@@ -251,10 +264,7 @@ async function handleContextCompression(runtime, event, ctx) {
251
264
  // until the actual compressible candidate material changes.
252
265
  runtime.state.lastInputFingerprint = inputFingerprint;
253
266
  runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
254
- // ponytail: clear stats.last so renderFooterStatus shows idle instead of stale savings
255
- runtime.state.stats.last = undefined;
256
- runtime.refreshStatus(ctx);
257
- return undefined;
267
+ return bypassUncompressed();
258
268
  }
259
269
  const applied = applyCompressionResult(event.messages, payload.mappings, result.messages, {
260
270
  minMessageChars: runtime.config.minMessageChars,
@@ -267,28 +277,79 @@ async function handleContextCompression(runtime, event, ctx) {
267
277
  recordGuardSkip(runtime.state.stats, applied.reason);
268
278
  emitNotraceTelemetry(runtime);
269
279
  announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
270
- runtime.refreshStatus(ctx);
271
- return undefined;
280
+ return bypassUncompressed();
272
281
  }
273
282
  // Store fingerprints to break the feedback loop
274
283
  runtime.state.lastInputFingerprint = inputFingerprint;
275
- runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
276
284
  runtime.state.lastGuardSkipCandidateFingerprint = null;
277
- recordSeenCandidateContent(runtime.state, payload, result.messages);
278
- recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
285
+ recordSeenCandidateContent(runtime.state, payload, applied.messages);
286
+ if (replayed) {
287
+ for (const mapping of payload.mappings) {
288
+ if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
289
+ applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
290
+ }
291
+ }
292
+ }
293
+ recordCachedCandidates(runtime.state, payload, applied.messages);
294
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
295
+ const appliedResult = {
296
+ ...result,
297
+ tokensBefore: applied.appliedTokensBefore,
298
+ tokensAfter: applied.appliedTokensAfter,
299
+ tokensSaved: applied.appliedTokensSaved,
300
+ compressionRatio: applied.appliedCompressionRatio,
301
+ };
302
+ recordAppliedCompression(runtime.state.stats, appliedResult, applied.appliedMessages);
279
303
  emitNotraceTelemetry(runtime);
280
- announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
304
+ announceAppliedCompression(runtime.pi, ctx, appliedResult, applied.appliedMessages, runtime.config.mode);
281
305
  runtime.refreshStatus(ctx);
282
306
  return { messages: applied.messages };
283
307
  }
284
308
  catch (error) {
285
309
  recordCompressionError(runtime, ctx, error);
286
- return undefined;
310
+ return bypassUncompressed();
287
311
  }
288
312
  finally {
289
313
  runtime.state.processing = false;
290
314
  }
291
315
  }
316
+ function candidateKey(message, text) {
317
+ const source = message;
318
+ return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
319
+ }
320
+ function replayCachedCandidates(state, messages, payload, minMessageChars) {
321
+ if (!state.compressedCandidates?.size)
322
+ return undefined;
323
+ const mappings = payload.mappings.map((mapping) => ({
324
+ ...mapping,
325
+ applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
326
+ ? mapping.applyTo : null,
327
+ }));
328
+ const compressed = mappings.map((mapping) => {
329
+ const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
330
+ return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
331
+ });
332
+ const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
333
+ return applied.ok ? applied.messages : undefined;
334
+ }
335
+ function recordCachedCandidates(state, payload, messages) {
336
+ state.compressedCandidates ??= new Map();
337
+ for (const mapping of payload.mappings) {
338
+ if (!mapping.applyTo)
339
+ continue;
340
+ const applied = convertMessage(messages[mapping.sourceIndex]);
341
+ if (!applied || applied.role !== "tool")
342
+ continue;
343
+ const text = extractOpenAIText(applied);
344
+ if (text === mapping.originalText)
345
+ continue;
346
+ const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
347
+ state.compressedCandidates.set(key, text);
348
+ if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
349
+ state.compressedCandidates.delete(state.compressedCandidates.keys().next().value);
350
+ }
351
+ }
352
+ }
292
353
  function allCandidateContentSeen(state, payload) {
293
354
  const seen = state.seenCandidateContentFingerprints;
294
355
  if (!seen?.size)
@@ -296,16 +357,25 @@ function allCandidateContentSeen(state, payload) {
296
357
  const hashes = payload.mappings.filter((mapping) => mapping.applyTo).map((mapping) => stableHash(mapping.originalText));
297
358
  return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
298
359
  }
299
- function recordSeenCandidateContent(state, payload, compressedMessages) {
360
+ function ignoreSeenCandidateContent(state, payload) {
361
+ const seen = state.seenCandidateContentFingerprints;
362
+ if (!seen?.size)
363
+ return;
364
+ for (const mapping of payload.mappings) {
365
+ if (mapping.applyTo && seen.has(stableHash(mapping.originalText)))
366
+ mapping.applyTo = null;
367
+ }
368
+ }
369
+ function recordSeenCandidateContent(state, payload, appliedMessages) {
300
370
  state.seenCandidateContentFingerprints ??= new Set();
301
371
  state.seenCandidateContentOrder ??= [];
302
- payload.mappings.forEach((mapping, index) => {
372
+ payload.mappings.forEach((mapping) => {
303
373
  if (!mapping.applyTo)
304
374
  return;
305
375
  addSeenCandidateHash(state, stableHash(mapping.originalText));
306
- const compressed = compressedMessages[index];
307
- if (compressed)
308
- addSeenCandidateHash(state, stableHash(extractOpenAIText(compressed)));
376
+ const applied = convertMessage(appliedMessages[mapping.sourceIndex]);
377
+ if (applied)
378
+ addSeenCandidateHash(state, stableHash(extractOpenAIText(applied)));
309
379
  });
310
380
  }
311
381
  function addSeenCandidateHash(state, hash) {
@@ -391,7 +461,7 @@ function recordAppliedCompression(stats, result, appliedMessages) {
391
461
  function summarizeTelemetry(state) {
392
462
  if (state.stats.last) {
393
463
  const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
394
- return `compressed ${state.stats.last.tokensBefore.toLocaleString()} to ${state.stats.last.tokensAfter.toLocaleString()} tokens; saved ${state.stats.last.tokensSaved.toLocaleString()} tokens across ${state.stats.last.appliedMessages} tool results (-${pct}%)`;
464
+ return `last compression estimated about ${state.stats.last.tokensSaved.toLocaleString()} tokens saved (-${pct}%) across ${state.stats.last.appliedMessages} tool results; local session estimate about ${state.stats.tokensSaved.toLocaleString()} tokens saved`;
395
465
  }
396
466
  if (!state.enabled)
397
467
  return "headroom loaded but disabled for this session";
@@ -443,7 +513,7 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages, mode = "no
443
513
  if (mode === "quiet" || mode === "silent")
444
514
  return;
445
515
  const pct = Math.round((1 - result.compressionRatio) * 100);
446
- const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
516
+ const summary = `compressed ${appliedMessages} tool results; estimated ~${result.tokensSaved.toLocaleString()} tokens saved (-${pct}%)`;
447
517
  const line = `noheadroom: ${summary}`;
448
518
  ctx.ui.notify(line, "info");
449
519
  // Note: we no longer use pi.appendEntry or pi.sendMessage here because
@@ -561,10 +631,7 @@ function renderFooterStatus(ctx, config, state) {
561
631
  return paint("dim", "○ Headroom not running");
562
632
  if (state.proxyOnline === null && !state.stats.last)
563
633
  return paint("dim", "○ Headroom idle");
564
- if (!state.stats.last)
565
- return paint("success", "✓") + paint("dim", " Headroom");
566
- const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
567
- return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`));
634
+ return paint("success", "✓") + paint("dim", " Headroom active");
568
635
  }
569
636
  async function showProxyStats(ctx, client, config) {
570
637
  if (isRemoteBlocked(config)) {
@@ -596,7 +663,8 @@ function renderStatus(config, state) {
596
663
  ` Attempts: ${stats.attempts}`,
597
664
  ` Applied: ${stats.applied}`,
598
665
  ` Guard skips: ${stats.guardSkips}`,
599
- ` Tokens saved: ${stats.tokensSaved.toLocaleString()}`,
666
+ ` Estimated tokens saved: ~${stats.tokensSaved.toLocaleString()}`,
667
+ ` Note: local estimate; proxy /stats does not track Pi extension /v1/compress calls.`,
600
668
  ];
601
669
  if (stats.last) {
602
670
  const pct = Math.round((1 - stats.last.compressionRatio) * 100);
@@ -13,27 +13,33 @@ export async function startPersistentHeadroomProxy(config) {
13
13
  env: {
14
14
  ...process.env,
15
15
  HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
16
+ HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
16
17
  },
17
18
  });
18
- child.unref();
19
- return { ok: true };
19
+ return await new Promise((resolve) => {
20
+ child.once("error", (error) => resolve({ ok: false, reason: error.message }));
21
+ child.once("spawn", () => {
22
+ child.unref();
23
+ resolve({ ok: true });
24
+ });
25
+ });
20
26
  }
21
27
  catch (error) {
22
28
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
23
29
  }
24
30
  }
25
31
  export function buildProxyArgs(endpoint) {
26
- return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "token", "--no-cache"];
32
+ return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
27
33
  }
28
34
  export function parseLocalEndpoint(baseUrl) {
29
35
  try {
30
36
  const url = new URL(baseUrl);
31
- if (!["http:", "https:"].includes(url.protocol))
37
+ if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
32
38
  return undefined;
33
39
  if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname))
34
40
  return undefined;
35
41
  const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
36
- const port = url.port || "8787";
42
+ const port = url.port || "80";
37
43
  return { host, port };
38
44
  }
39
45
  catch {
package/dist/types.d.ts CHANGED
@@ -96,6 +96,10 @@ export type ApplyCompressionResult = {
96
96
  ok: true;
97
97
  messages: AgentMessage[];
98
98
  appliedMessages: number;
99
+ appliedTokensBefore: number;
100
+ appliedTokensAfter: number;
101
+ appliedTokensSaved: number;
102
+ appliedCompressionRatio: number;
99
103
  } | {
100
104
  ok: false;
101
105
  reason: string;
@@ -17,6 +17,9 @@ interface MessageWithContent {
17
17
  }
18
18
 
19
19
  const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
20
+ // File reads supply exact edit anchors; directives and recovered evidence must stay raw.
21
+ const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
22
+ const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
20
23
 
21
24
  export function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload {
22
25
  const mappings: CompressionMapping[] = [];
@@ -30,7 +33,13 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
30
33
  const originalText = extractOpenAIText(converted);
31
34
  // Mark candidates for compression.
32
35
  // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
33
- let applyTo: "toolResult" | null = source.role === "toolResult" ? "toolResult" : null;
36
+ const textOnly = typeof source.content === "string" ||
37
+ (Array.isArray(source.content) && source.content.every(isTextContent));
38
+ let applyTo: "toolResult" | null = source.role === "toolResult" &&
39
+ textOnly && originalText.length >= minMessageChars &&
40
+ !("isError" in source && source.isError) &&
41
+ !PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
42
+ ? "toolResult" : null;
34
43
 
35
44
  // Headroom Bypass Rules (Android Hat)
36
45
  // We never want to compress `android layout` JSON dumps or critical adb dumps.
@@ -38,7 +47,7 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
38
47
  applyTo = null;
39
48
  }
40
49
 
41
- if (applyTo && originalText.length >= minMessageChars) candidateCount++;
50
+ if (applyTo) candidateCount++;
42
51
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
43
52
  }
44
53
 
@@ -53,14 +62,16 @@ export function applyCompressionResult(
53
62
  originalMessages: AgentMessage[],
54
63
  mappings: CompressionMapping[],
55
64
  compressedMessages: OpenAIMessage[],
56
- _options: ApplyCompressionOptions,
65
+ options: ApplyCompressionOptions,
57
66
  ): ApplyCompressionResult {
58
- if (compressedMessages.length !== mappings.length) {
67
+ if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
59
68
  return { ok: false, reason: "message-count-changed" };
60
69
  }
61
70
 
62
71
  const nextMessages = structuredClone(originalMessages) as AgentMessage[];
63
72
  let appliedMessages = 0;
73
+ let appliedTokensBefore = 0;
74
+ let appliedTokensAfter = 0;
64
75
 
65
76
  for (let index = 0; index < mappings.length; index++) {
66
77
  const mapping = mappings[index];
@@ -69,29 +80,44 @@ export function applyCompressionResult(
69
80
  // We only validate and apply changes to explicit candidates.
70
81
  // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
71
82
  // but we simply ignore those changes and keep the original Pi message intact.
72
- if (!mapping.applyTo) continue;
83
+ if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars) continue;
73
84
 
74
85
  const validation = validateAlignedMessage(mapping.message, compressed);
75
86
  if (!validation.ok) return validation;
76
87
 
77
- let nextText = extractOpenAIText(compressed);
88
+ const nextText = extractOpenAIText(compressed);
78
89
  if (nextText === mapping.originalText) continue;
79
-
80
- // Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
81
- nextText = naturalizeHeadroomMarkers(nextText);
90
+ // No retrieval executor is registered. Hiding a hash does not recover evidence.
91
+ if (CCR_MARKER.test(nextText)) return { ok: false, reason: "unrecoverable-ccr-marker" };
92
+ if (!nextText.trim()) return { ok: false, reason: "empty-compressed-content" };
93
+ if (estimateTokens(nextText) >= estimateTokens(mapping.originalText)) continue;
82
94
 
83
95
  const target = nextMessages[mapping.sourceIndex] as AnyMessage;
84
96
  if (!hasContent(target) || !replaceTextContent(target, nextText)) {
85
97
  return { ok: false, reason: "target-content-unreplaceable" };
86
98
  }
99
+ appliedTokensBefore += estimateTokens(mapping.originalText);
100
+ appliedTokensAfter += estimateTokens(nextText);
87
101
  appliedMessages++;
88
102
  }
89
103
 
90
104
  if (appliedMessages === 0) {
91
105
  return { ok: false, reason: "no-applicable-message-changed" };
92
106
  }
107
+ const appliedTokensSaved = Math.max(0, appliedTokensBefore - appliedTokensAfter);
108
+ if (appliedTokensSaved === 0) {
109
+ return { ok: false, reason: "no-estimated-token-savings" };
110
+ }
93
111
 
94
- return { ok: true, messages: nextMessages, appliedMessages };
112
+ return {
113
+ ok: true,
114
+ messages: nextMessages,
115
+ appliedMessages,
116
+ appliedTokensBefore,
117
+ appliedTokensAfter,
118
+ appliedTokensSaved,
119
+ appliedCompressionRatio: appliedTokensAfter / appliedTokensBefore,
120
+ };
95
121
  }
96
122
 
97
123
  export function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
@@ -159,10 +185,9 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
159
185
  id: toolCall.id,
160
186
  type: "function",
161
187
  function: {
162
- // Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
163
- // Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
164
- name: "pi_tool_result",
165
- arguments: JSON.stringify({ originalToolName: toolCall.name }),
188
+ // Preserve upstream tool protections as well as routing context.
189
+ name: toolCall.name,
190
+ arguments: JSON.stringify(toolCall.arguments),
166
191
  },
167
192
  };
168
193
  }
@@ -171,6 +196,9 @@ function validateAlignedMessage(
171
196
  original: OpenAIMessage,
172
197
  compressed: OpenAIMessage,
173
198
  ): { ok: true } | { ok: false; reason: string } {
199
+ if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
200
+ return { ok: false, reason: "invalid-compressed-message" };
201
+ }
174
202
  if (original.role !== compressed.role) {
175
203
  return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
176
204
  }
@@ -267,29 +295,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
267
295
  return typeof value === "object" && value !== null;
268
296
  }
269
297
 
270
- function naturalizeHeadroomMarkers(text: string): string {
271
- // Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
272
- // into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
273
- return text.replace(
274
- /\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi,
275
- "[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]"
276
- );
277
- }
278
-
279
- export function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[] {
280
- // If a system message already exists at the top, we append the hint to it.
281
- // Otherwise, we inject a new system message at index 0.
282
- const result = [...messages];
283
- const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
284
-
285
- const first = result[0] as unknown as Record<string, unknown>;
286
- if (first && first.role === "system" && typeof first.content === "string") {
287
- if (!first.content.includes("automatically compressed by Headroom")) {
288
- result[0] = { ...first, content: `${first.content}\n\n${hintText}` } as unknown as AgentMessage;
289
- }
290
- } else {
291
- result.unshift({ role: "system", content: hintText } as unknown as AgentMessage);
292
- }
293
-
294
- return result;
298
+ function estimateTokens(text: string): number {
299
+ // ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
300
+ return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
295
301
  }
@@ -27,12 +27,12 @@ export class HeadroomHttpClient {
27
27
  async health(signal?: AbortSignal): Promise<boolean> {
28
28
  try {
29
29
  const response = await fetch(`${this.baseUrl}/health`, {
30
+ redirect: "error",
30
31
  signal: buildSignal(this.timeoutMs, signal),
31
32
  });
32
33
  if (!response.ok) return false;
33
34
  const body = await readJsonObject(response);
34
- if (!body) return true;
35
- return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
35
+ return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
36
36
  } catch {
37
37
  return false;
38
38
  }
@@ -41,12 +41,15 @@ export class HeadroomHttpClient {
41
41
  async probe(signal?: AbortSignal): Promise<boolean> {
42
42
  try {
43
43
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
44
+ redirect: "error",
44
45
  method: "POST",
45
46
  headers: { "Content-Type": "application/json" },
46
47
  body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
47
48
  signal: buildSignal(5_000, signal),
48
49
  });
49
- return response.ok;
50
+ if (!response.ok) return false;
51
+ validateCompressionResponse(await response.json());
52
+ return true;
50
53
  } catch {
51
54
  return false;
52
55
  }
@@ -54,6 +57,7 @@ export class HeadroomHttpClient {
54
57
 
55
58
  async stats(signal?: AbortSignal): Promise<unknown> {
56
59
  const response = await fetch(`${this.baseUrl}/stats`, {
60
+ redirect: "error",
57
61
  signal: buildSignal(this.timeoutMs, signal),
58
62
  });
59
63
  if (!response.ok) {
@@ -64,12 +68,18 @@ export class HeadroomHttpClient {
64
68
 
65
69
  async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
66
70
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
71
+ redirect: "error",
67
72
  method: "POST",
68
73
  headers: {
69
74
  "Content-Type": "application/json",
70
75
  "X-Headroom-Stack": "pi-extension",
71
76
  },
72
- body: JSON.stringify({ messages, model: model || "gpt-4o" }),
77
+ body: JSON.stringify({
78
+ messages,
79
+ model: model || "gpt-4o",
80
+ // 0.39.1's default is marker-free and inherits the backend's --lossless policy.
81
+ // lossy_inline would override that policy and enable lossy SmartCrusher.
82
+ }),
73
83
  signal: buildSignal(this.timeoutMs, signal),
74
84
  });
75
85
 
@@ -78,7 +88,7 @@ export class HeadroomHttpClient {
78
88
  throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
79
89
  }
80
90
 
81
- const payload = (await response.json()) as ProxyCompressResponse;
91
+ const payload = validateCompressionResponse(await response.json());
82
92
  return {
83
93
  messages: payload.messages,
84
94
  tokensBefore: payload.tokens_before,
@@ -92,6 +102,18 @@ export class HeadroomHttpClient {
92
102
  }
93
103
  }
94
104
 
105
+ function validateCompressionResponse(value: unknown): ProxyCompressResponse {
106
+ const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
107
+ const stringArray = (v: unknown) => Array.isArray(v) && v.every((item) => typeof item === "string");
108
+ if (!isRecord(value) || !Array.isArray(value.messages) ||
109
+ !metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
110
+ (value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
111
+ (value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || (value.ccr_hashes as string[]).length > 0))) {
112
+ throw new Error("Invalid or retrieval-dependent Headroom compression response");
113
+ }
114
+ return value as unknown as ProxyCompressResponse;
115
+ }
116
+
95
117
  async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
96
118
  try {
97
119
  const body = (await response.json()) as unknown;
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import type { HeadroomConfig, HeadroomMode } from "./types.js";
5
5
 
6
6
  const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
7
- const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
7
+ const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
8
8
  const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
9
9
  const DEFAULT_TIMEOUT_MS = 30_000;
10
10
 
@@ -84,7 +84,8 @@ export function loadHeadroomConfig(
84
84
  export function isLocalHeadroomUrl(rawUrl: string): boolean {
85
85
  try {
86
86
  const url = new URL(rawUrl);
87
- return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
87
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
88
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
88
89
  } catch {
89
90
  return false;
90
91
  }
@@ -36,6 +36,7 @@ interface HeadroomRuntimeState {
36
36
  lastGuardSkipCandidateFingerprint: string | null;
37
37
  seenCandidateContentFingerprints?: Set<string>;
38
38
  seenCandidateContentOrder?: string[];
39
+ compressedCandidates?: Map<string, string>;
39
40
  lastCompressionTime: number;
40
41
  stats: HeadroomStats;
41
42
  }
@@ -67,6 +68,14 @@ export default function headroomExtension(pi: ExtensionAPI) {
67
68
  const runtime = createRuntime(pi);
68
69
 
69
70
  pi.on("session_start", (_event, ctx) => {
71
+ // A context transform is request-local; cached substitutions must not cross sessions.
72
+ runtime.state.compressedCandidates = new Map();
73
+ runtime.state.seenCandidateContentFingerprints = new Set();
74
+ runtime.state.seenCandidateContentOrder = [];
75
+ runtime.state.lastInputFingerprint = null;
76
+ runtime.state.lastOutputFingerprint = null;
77
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
78
+ runtime.state.lastCompressionTime = 0;
70
79
  if (isRemoteBlocked(runtime.config)) {
71
80
  runtime.refreshStatus(ctx);
72
81
  emitNotraceTelemetry(runtime);
@@ -135,6 +144,7 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
135
144
  lastGuardSkipCandidateFingerprint: null,
136
145
  seenCandidateContentFingerprints: new Set(),
137
146
  seenCandidateContentOrder: [],
147
+ compressedCandidates: new Map(),
138
148
  lastCompressionTime: 0,
139
149
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
140
150
  };
@@ -270,41 +280,40 @@ async function handleContextCompression(
270
280
  ): Promise<{ messages?: AgentMessage[] } | undefined> {
271
281
  if (runtime.state.processing) return undefined;
272
282
 
273
- // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
274
- const now = Date.now();
275
- if (now - runtime.state.lastCompressionTime < 3000) {
276
- return undefined;
277
- }
278
-
279
- // Content-based guards to prevent infinite recursion
280
- const inputFingerprint = generateFingerprint(event.messages);
281
-
282
- // 1. If this input matches our previous output, we already compressed it. Stop.
283
- if (runtime.state.lastOutputFingerprint === inputFingerprint) {
284
- return undefined;
285
- }
286
-
287
- // 2. If this input matches our previous input, it didn't change. Stop.
288
- if (runtime.state.lastInputFingerprint === inputFingerprint) {
289
- return undefined;
290
- }
283
+ let replayed: AgentMessage[] | undefined;
284
+ const bypassUncompressed = () => {
285
+ if (runtime.state.stats.last) {
286
+ runtime.state.stats.last = undefined;
287
+ runtime.refreshStatus(ctx);
288
+ }
289
+ return replayed ? { messages: replayed } : undefined;
290
+ };
291
291
 
292
- if (shouldSkipBeforePayload(runtime, ctx)) return undefined;
292
+ if (shouldSkipBeforePayload(runtime, ctx)) return bypassUncompressed();
293
293
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
294
- if (payload.candidateCount === 0) return undefined;
294
+ replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
295
+ if (payload.candidateCount === 0) return bypassUncompressed();
296
+
297
+ // Pi's context transform is request-local; reuse validated text on later turns.
298
+ const now = Date.now();
299
+ if (now - runtime.state.lastCompressionTime < 3000) return bypassUncompressed();
300
+ const inputFingerprint = generateFingerprint(event.messages);
301
+ if (runtime.state.lastOutputFingerprint === inputFingerprint) return undefined;
302
+ if (runtime.state.lastInputFingerprint === inputFingerprint) return bypassUncompressed();
295
303
 
296
304
  // 3. If eligible candidates haven't changed since the last skip/no-savings result,
297
305
  // don't spend another proxy call just because surrounding conversation changed.
298
306
  const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
299
307
  if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
300
- return undefined;
308
+ return bypassUncompressed();
301
309
  }
302
310
  if (allCandidateContentSeen(runtime.state, payload)) {
303
- return undefined;
311
+ return bypassUncompressed();
304
312
  }
313
+ ignoreSeenCandidateContent(runtime.state, payload);
305
314
  if (runtime.state.proxyOnline !== true) {
306
315
  void ensureProxyInBackground(runtime, ctx);
307
- return undefined;
316
+ return bypassUncompressed();
308
317
  }
309
318
 
310
319
  runtime.state.processing = true;
@@ -318,10 +327,7 @@ async function handleContextCompression(
318
327
  // until the actual compressible candidate material changes.
319
328
  runtime.state.lastInputFingerprint = inputFingerprint;
320
329
  runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
321
- // ponytail: clear stats.last so renderFooterStatus shows idle instead of stale savings
322
- runtime.state.stats.last = undefined;
323
- runtime.refreshStatus(ctx);
324
- return undefined;
330
+ return bypassUncompressed();
325
331
  }
326
332
 
327
333
  const applied = applyCompressionResult(event.messages, payload.mappings, result.messages, {
@@ -336,29 +342,81 @@ async function handleContextCompression(
336
342
  recordGuardSkip(runtime.state.stats, applied.reason);
337
343
  emitNotraceTelemetry(runtime);
338
344
  announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
339
- runtime.refreshStatus(ctx);
340
- return undefined;
345
+ return bypassUncompressed();
341
346
  }
342
347
 
343
348
  // Store fingerprints to break the feedback loop
344
349
  runtime.state.lastInputFingerprint = inputFingerprint;
345
- runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
346
350
  runtime.state.lastGuardSkipCandidateFingerprint = null;
347
- recordSeenCandidateContent(runtime.state, payload, result.messages);
348
-
349
- recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
351
+ recordSeenCandidateContent(runtime.state, payload, applied.messages);
352
+ if (replayed) {
353
+ for (const mapping of payload.mappings) {
354
+ if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
355
+ applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
356
+ }
357
+ }
358
+ }
359
+ recordCachedCandidates(runtime.state, payload, applied.messages);
360
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
361
+ const appliedResult = {
362
+ ...result,
363
+ tokensBefore: applied.appliedTokensBefore,
364
+ tokensAfter: applied.appliedTokensAfter,
365
+ tokensSaved: applied.appliedTokensSaved,
366
+ compressionRatio: applied.appliedCompressionRatio,
367
+ };
368
+
369
+ recordAppliedCompression(runtime.state.stats, appliedResult, applied.appliedMessages);
350
370
  emitNotraceTelemetry(runtime);
351
- announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
371
+ announceAppliedCompression(runtime.pi, ctx, appliedResult, applied.appliedMessages, runtime.config.mode);
352
372
  runtime.refreshStatus(ctx);
353
373
  return { messages: applied.messages };
354
374
  } catch (error) {
355
375
  recordCompressionError(runtime, ctx, error);
356
- return undefined;
376
+ return bypassUncompressed();
357
377
  } finally {
358
378
  runtime.state.processing = false;
359
379
  }
360
380
  }
361
381
 
382
+ function candidateKey(message: AgentMessage, text: string): string {
383
+ const source = message as AgentMessage & { toolCallId?: string; toolName?: string };
384
+ return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
385
+ }
386
+
387
+ function replayCachedCandidates(
388
+ state: HeadroomRuntimeState, messages: AgentMessage[], payload: CompressionPayload, minMessageChars: number,
389
+ ): AgentMessage[] | undefined {
390
+ if (!state.compressedCandidates?.size) return undefined;
391
+ const mappings = payload.mappings.map((mapping) => ({
392
+ ...mapping,
393
+ applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
394
+ ? mapping.applyTo : null,
395
+ }));
396
+ const compressed = mappings.map((mapping) => {
397
+ const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
398
+ return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
399
+ });
400
+ const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
401
+ return applied.ok ? applied.messages : undefined;
402
+ }
403
+
404
+ function recordCachedCandidates(state: HeadroomRuntimeState, payload: CompressionPayload, messages: AgentMessage[]): void {
405
+ state.compressedCandidates ??= new Map();
406
+ for (const mapping of payload.mappings) {
407
+ if (!mapping.applyTo) continue;
408
+ const applied = convertMessage(messages[mapping.sourceIndex]);
409
+ if (!applied || applied.role !== "tool") continue;
410
+ const text = extractOpenAIText(applied);
411
+ if (text === mapping.originalText) continue;
412
+ const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
413
+ state.compressedCandidates.set(key, text);
414
+ if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
415
+ state.compressedCandidates.delete(state.compressedCandidates.keys().next().value!);
416
+ }
417
+ }
418
+ }
419
+
362
420
  function allCandidateContentSeen(state: HeadroomRuntimeState, payload: CompressionPayload): boolean {
363
421
  const seen = state.seenCandidateContentFingerprints;
364
422
  if (!seen?.size) return false;
@@ -366,18 +424,26 @@ function allCandidateContentSeen(state: HeadroomRuntimeState, payload: Compressi
366
424
  return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
367
425
  }
368
426
 
427
+ function ignoreSeenCandidateContent(state: HeadroomRuntimeState, payload: CompressionPayload): void {
428
+ const seen = state.seenCandidateContentFingerprints;
429
+ if (!seen?.size) return;
430
+ for (const mapping of payload.mappings) {
431
+ if (mapping.applyTo && seen.has(stableHash(mapping.originalText))) mapping.applyTo = null;
432
+ }
433
+ }
434
+
369
435
  function recordSeenCandidateContent(
370
436
  state: HeadroomRuntimeState,
371
437
  payload: CompressionPayload,
372
- compressedMessages: CompressionPayload["messages"],
438
+ appliedMessages: AgentMessage[],
373
439
  ): void {
374
440
  state.seenCandidateContentFingerprints ??= new Set();
375
441
  state.seenCandidateContentOrder ??= [];
376
- payload.mappings.forEach((mapping, index) => {
442
+ payload.mappings.forEach((mapping) => {
377
443
  if (!mapping.applyTo) return;
378
444
  addSeenCandidateHash(state, stableHash(mapping.originalText));
379
- const compressed = compressedMessages[index];
380
- if (compressed) addSeenCandidateHash(state, stableHash(extractOpenAIText(compressed)));
445
+ const applied = convertMessage(appliedMessages[mapping.sourceIndex]);
446
+ if (applied) addSeenCandidateHash(state, stableHash(extractOpenAIText(applied)));
381
447
  });
382
448
  }
383
449
 
@@ -472,7 +538,7 @@ function recordAppliedCompression(stats: HeadroomStats, result: CompressResult,
472
538
  function summarizeTelemetry(state: HeadroomRuntimeState): string | null {
473
539
  if (state.stats.last) {
474
540
  const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
475
- return `compressed ${state.stats.last.tokensBefore.toLocaleString()} to ${state.stats.last.tokensAfter.toLocaleString()} tokens; saved ${state.stats.last.tokensSaved.toLocaleString()} tokens across ${state.stats.last.appliedMessages} tool results (-${pct}%)`;
541
+ return `last compression estimated about ${state.stats.last.tokensSaved.toLocaleString()} tokens saved (-${pct}%) across ${state.stats.last.appliedMessages} tool results; local session estimate about ${state.stats.tokensSaved.toLocaleString()} tokens saved`;
476
542
  }
477
543
  if (!state.enabled) return "headroom loaded but disabled for this session";
478
544
  if (state.stats.lastSkipReason) return `compression not applied; last guard skip: ${state.stats.lastSkipReason}`;
@@ -524,7 +590,7 @@ function announceAppliedCompression(
524
590
  ): void {
525
591
  if (mode === "quiet" || mode === "silent") return;
526
592
  const pct = Math.round((1 - result.compressionRatio) * 100);
527
- const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
593
+ const summary = `compressed ${appliedMessages} tool results; estimated ~${result.tokensSaved.toLocaleString()} tokens saved (-${pct}%)`;
528
594
  const line = `noheadroom: ${summary}`;
529
595
  ctx.ui.notify(line, "info");
530
596
  // Note: we no longer use pi.appendEntry or pi.sendMessage here because
@@ -661,12 +727,7 @@ function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state
661
727
  if (state.proxyStarting) return paint("dim", "⏳ Headroom starting");
662
728
  if (state.proxyOnline === false) return paint("dim", "○ Headroom not running");
663
729
  if (state.proxyOnline === null && !state.stats.last) return paint("dim", "○ Headroom idle");
664
- if (!state.stats.last) return paint("success", "✓") + paint("dim", " Headroom");
665
-
666
- const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
667
- return (
668
- paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`)
669
- );
730
+ return paint("success", "✓") + paint("dim", " Headroom active");
670
731
  }
671
732
 
672
733
  async function showProxyStats(
@@ -706,7 +767,8 @@ function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): stri
706
767
  ` Attempts: ${stats.attempts}`,
707
768
  ` Applied: ${stats.applied}`,
708
769
  ` Guard skips: ${stats.guardSkips}`,
709
- ` Tokens saved: ${stats.tokensSaved.toLocaleString()}`,
770
+ ` Estimated tokens saved: ~${stats.tokensSaved.toLocaleString()}`,
771
+ ` Note: local estimate; proxy /stats does not track Pi extension /v1/compress calls.`,
710
772
  ];
711
773
  if (stats.last) {
712
774
  const pct = Math.round((1 - stats.last.compressionRatio) * 100);
@@ -22,26 +22,32 @@ export async function startPersistentHeadroomProxy(
22
22
  env: {
23
23
  ...process.env,
24
24
  HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
25
+ HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
25
26
  },
26
27
  });
27
- child.unref();
28
- return { ok: true };
28
+ return await new Promise((resolve) => {
29
+ child.once("error", (error) => resolve({ ok: false, reason: error.message }));
30
+ child.once("spawn", () => {
31
+ child.unref();
32
+ resolve({ ok: true });
33
+ });
34
+ });
29
35
  } catch (error) {
30
36
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
31
37
  }
32
38
  }
33
39
 
34
40
  export function buildProxyArgs(endpoint: ProxyEndpoint): string[] {
35
- return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "token", "--no-cache"];
41
+ return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
36
42
  }
37
43
 
38
44
  export function parseLocalEndpoint(baseUrl: string): ProxyEndpoint | undefined {
39
45
  try {
40
46
  const url = new URL(baseUrl);
41
- if (!["http:", "https:"].includes(url.protocol)) return undefined;
47
+ if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return undefined;
42
48
  if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)) return undefined;
43
49
  const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
44
- const port = url.port || "8787";
50
+ const port = url.port || "80";
45
51
  return { host, port };
46
52
  } catch {
47
53
  return undefined;
@@ -108,5 +108,13 @@ export interface ApplyCompressionOptions {
108
108
  }
109
109
 
110
110
  export type ApplyCompressionResult =
111
- | { ok: true; messages: AgentMessage[]; appliedMessages: number }
111
+ | {
112
+ ok: true;
113
+ messages: AgentMessage[];
114
+ appliedMessages: number;
115
+ appliedTokensBefore: number;
116
+ appliedTokensAfter: number;
117
+ appliedTokensSaved: number;
118
+ appliedCompressionRatio: number;
119
+ }
112
120
  | { ok: false; reason: string };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Personal Headroom compression bridge for Pi with Docker-first defaults and Pi tool-name adaptation",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",