@raquezha/noheadroom 0.2.1 → 0.2.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
@@ -2,7 +2,7 @@
2
2
 
3
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).
4
4
 
5
- `noheadroom` sits between Pi and your LLM, shrinking massive tool results and logs before they reach the model. Save tokens, keep more history, and prevent context-overflow in long sessions.
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
 
@@ -17,7 +17,10 @@ Upstream Headroom protects common agent tool names like `read` and `bash` by def
17
17
 
18
18
  ## ✨ Features
19
19
 
20
+ - **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.
20
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.
21
24
  - **Pi-Native Metadata Preservation**: original tool IDs and names are never modified in your real session.
22
25
  - **Deep Visibility**: compression results appear in your terminal, the Pi footer, and as persistent entries in your session history.
23
26
  - **Docker-First Architecture**: designed to work seamlessly with a local containerized backend.
package/dist/bridge.js CHANGED
@@ -8,10 +8,14 @@ export function buildCompressionPayload(messages, minMessageChars) {
8
8
  if (!converted)
9
9
  continue;
10
10
  const originalText = extractOpenAIText(converted);
11
- // Allow any toolResult to be a candidate for compression if Headroom decides to shrink it.
12
- // The minMessageChars threshold is primarily to avoid overhead for tiny messages,
13
- // but we shouldn't block Headroom if it finds savings in slightly smaller ones.
14
- const applyTo = source.role === "toolResult" ? "toolResult" : null;
11
+ // Mark candidates for compression.
12
+ // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
13
+ let applyTo = source.role === "toolResult" ? "toolResult" : null;
14
+ // Headroom Bypass Rules (Android Hat)
15
+ // We never want to compress `android layout` JSON dumps or critical adb dumps.
16
+ if (source.role === "toolResult" && originalText.trim().startsWith("[") && originalText.includes('"resource-id"')) {
17
+ applyTo = null;
18
+ }
15
19
  if (applyTo && originalText.length >= minMessageChars)
16
20
  candidateCount++;
17
21
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
@@ -31,20 +35,19 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
31
35
  for (let index = 0; index < mappings.length; index++) {
32
36
  const mapping = mappings[index];
33
37
  const compressed = compressedMessages[index];
38
+ // We only validate and apply changes to explicit candidates.
39
+ // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
40
+ // but we simply ignore those changes and keep the original Pi message intact.
41
+ if (!mapping.applyTo)
42
+ continue;
34
43
  const validation = validateAlignedMessage(mapping.message, compressed);
35
44
  if (!validation.ok)
36
45
  return validation;
37
- const nextText = extractOpenAIText(compressed);
46
+ let nextText = extractOpenAIText(compressed);
38
47
  if (nextText === mapping.originalText)
39
48
  continue;
40
- if (mapping.applyTo !== "toolResult") {
41
- return { ok: false, reason: `non-candidate-changed:${mapping.message.role}` };
42
- }
43
49
  const target = nextMessages[mapping.sourceIndex];
44
- if (target.role !== "toolResult") {
45
- return { ok: false, reason: "source-role-mismatch" };
46
- }
47
- if (!replaceTextContent(target, nextText)) {
50
+ if (!hasContent(target) || !replaceTextContent(target, nextText)) {
48
51
  return { ok: false, reason: "target-content-unreplaceable" };
49
52
  }
50
53
  appliedMessages++;
@@ -174,8 +177,22 @@ function replaceTextContent(message, text) {
174
177
  }
175
178
  if (!Array.isArray(message.content))
176
179
  return false;
177
- const imageParts = message.content.filter((part) => isImageContent(part));
178
- message.content = [{ type: "text", text }, ...imageParts];
180
+ const nextContent = [];
181
+ let replacedText = false;
182
+ for (const part of message.content) {
183
+ if (isTextContent(part)) {
184
+ if (!replacedText) {
185
+ nextContent.push({ type: "text", text });
186
+ replacedText = true;
187
+ }
188
+ continue;
189
+ }
190
+ nextContent.push(part);
191
+ }
192
+ if (!replacedText && text.length > 0) {
193
+ nextContent.unshift({ type: "text", text });
194
+ }
195
+ message.content = nextContent;
179
196
  return true;
180
197
  }
181
198
  function hasContent(message) {
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ interface HeadroomRuntimeState {
11
11
  processing: boolean;
12
12
  lastInputFingerprint: string | null;
13
13
  lastOutputFingerprint: string | null;
14
+ lastGuardSkipCandidateFingerprint: string | null;
14
15
  lastCompressionTime: number;
15
16
  stats: HeadroomStats;
16
17
  }
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { Box, Text } from "@earendil-works/pi-tui";
2
3
  import { applyCompressionResult, buildCompressionPayload, convertMessage, extractOpenAIText, } from "./bridge.js";
3
4
  import { HeadroomHttpClient } from "./client.js";
@@ -5,6 +6,7 @@ import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
5
6
  import { startPersistentHeadroomProxy } from "./proxy-manager.js";
6
7
  const STATUS_KEY = "headroom";
7
8
  const SUBCOMMANDS = ["status", "on", "off", "health", "stats"];
9
+ const NOTRACE_TELEMETRY_CHANNEL = "notrace.telemetry.extension";
8
10
  const COMPRESSION_ENTRY_TYPE = "noheadroom.compression";
9
11
  export default function headroomExtension(pi) {
10
12
  registerCompressionRenderer(pi);
@@ -12,10 +14,12 @@ export default function headroomExtension(pi) {
12
14
  pi.on("session_start", (_event, ctx) => {
13
15
  if (isRemoteBlocked(runtime.config)) {
14
16
  runtime.refreshStatus(ctx);
17
+ emitNotraceTelemetry(runtime);
15
18
  ctx.ui.notify(`Headroom remote URL is blocked by default: ${runtime.config.baseUrl}\nSet PI_HEADROOM_ALLOW_REMOTE=1 only if you trust that proxy with full context.`, "warning");
16
19
  return;
17
20
  }
18
21
  runtime.refreshStatus(ctx);
22
+ emitNotraceTelemetry(runtime);
19
23
  if (!runtime.state.enabled)
20
24
  return;
21
25
  void ensureProxyInBackground(runtime, ctx);
@@ -52,6 +56,7 @@ function createRuntime(pi) {
52
56
  processing: false,
53
57
  lastInputFingerprint: null,
54
58
  lastOutputFingerprint: null,
59
+ lastGuardSkipCandidateFingerprint: null,
55
60
  lastCompressionTime: 0,
56
61
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
57
62
  };
@@ -195,6 +200,12 @@ async function handleContextCompression(runtime, event, ctx) {
195
200
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
196
201
  if (payload.candidateCount === 0)
197
202
  return undefined;
203
+ // 3. If eligible candidates haven't changed since the last skip/no-savings result,
204
+ // don't spend another proxy call just because surrounding conversation changed.
205
+ const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
206
+ if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
207
+ return undefined;
208
+ }
198
209
  if (runtime.state.proxyOnline !== true) {
199
210
  void ensureProxyInBackground(runtime, ctx);
200
211
  return undefined;
@@ -206,8 +217,10 @@ async function handleContextCompression(runtime, event, ctx) {
206
217
  const result = await runtime.client.compress(payload.messages, ctx.model?.id, ctx.signal);
207
218
  runtime.state.proxyOnline = true;
208
219
  if (!result.compressed || result.tokensSaved <= 0) {
209
- // Even if no tokens saved, record the input so we don't keep trying
220
+ // Even if no tokens saved, record fingerprints so we don't keep trying
221
+ // until the actual compressible candidate material changes.
210
222
  runtime.state.lastInputFingerprint = inputFingerprint;
223
+ runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
211
224
  runtime.refreshStatus(ctx);
212
225
  return undefined;
213
226
  }
@@ -215,7 +228,12 @@ async function handleContextCompression(runtime, event, ctx) {
215
228
  minMessageChars: runtime.config.minMessageChars,
216
229
  });
217
230
  if (!applied.ok) {
231
+ // Record the input even on guard skips to prevent looping retries for this context
232
+ runtime.state.lastInputFingerprint = inputFingerprint;
233
+ runtime.state.lastOutputFingerprint = null;
234
+ runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
218
235
  recordGuardSkip(runtime.state.stats, applied.reason);
236
+ emitNotraceTelemetry(runtime);
219
237
  announceGuardSkip(ctx, applied.reason, result);
220
238
  runtime.refreshStatus(ctx);
221
239
  return undefined;
@@ -223,7 +241,9 @@ async function handleContextCompression(runtime, event, ctx) {
223
241
  // Store fingerprints to break the feedback loop
224
242
  runtime.state.lastInputFingerprint = inputFingerprint;
225
243
  runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
244
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
226
245
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
246
+ emitNotraceTelemetry(runtime);
227
247
  announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
228
248
  runtime.refreshStatus(ctx);
229
249
  return { messages: applied.messages };
@@ -236,12 +256,45 @@ async function handleContextCompression(runtime, event, ctx) {
236
256
  runtime.state.processing = false;
237
257
  }
238
258
  }
259
+ function generateCandidateFingerprint(messages, payload) {
260
+ const units = payload.mappings
261
+ .filter((mapping) => mapping.applyTo)
262
+ .map((mapping) => {
263
+ const source = messages[mapping.sourceIndex];
264
+ return {
265
+ applyTo: mapping.applyTo,
266
+ sourceIndex: mapping.sourceIndex,
267
+ role: source.role,
268
+ toolCallId: typeof source.toolCallId === "string" ? source.toolCallId : null,
269
+ toolName: typeof source.toolName === "string" ? source.toolName : null,
270
+ contentShape: describeContentShape(source.content),
271
+ textLength: mapping.originalText.length,
272
+ textHash: stableHash(mapping.originalText),
273
+ };
274
+ });
275
+ return stableHash(JSON.stringify(units));
276
+ }
277
+ function describeContentShape(content) {
278
+ if (Array.isArray(content)) {
279
+ return content
280
+ .map((part) => {
281
+ if (!part || typeof part !== "object" || !("type" in part))
282
+ return "unknown";
283
+ return String(part.type ?? "unknown");
284
+ })
285
+ .join(",");
286
+ }
287
+ return typeof content;
288
+ }
289
+ function stableHash(value) {
290
+ return createHash("sha256").update(value).digest("hex");
291
+ }
239
292
  function generateFingerprint(messages) {
240
293
  return messages
241
294
  .map((m) => {
242
295
  const converted = convertMessage(m);
243
- const len = converted ? extractOpenAIText(converted).length : 0;
244
- return `${m.role}:${len}`;
296
+ const text = converted ? extractOpenAIText(converted) : "";
297
+ return `${m.role}:${text.length}:${stableHash(text)}`;
245
298
  })
246
299
  .join(",");
247
300
  }
@@ -270,6 +323,57 @@ function recordAppliedCompression(stats, result, appliedMessages) {
270
323
  stats.lastSkipReason = undefined;
271
324
  stats.last = { ...result, appliedMessages };
272
325
  }
326
+ function summarizeTelemetry(state) {
327
+ if (state.stats.last) {
328
+ const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
329
+ 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}%)`;
330
+ }
331
+ if (!state.enabled)
332
+ return "headroom loaded but disabled for this session";
333
+ if (state.stats.lastSkipReason)
334
+ return `compression not applied; last guard skip: ${state.stats.lastSkipReason}`;
335
+ if (state.stats.lastError)
336
+ return `compression unavailable; last error: ${state.stats.lastError}`;
337
+ if (state.proxyOnline === false)
338
+ return "proxy unavailable";
339
+ if (state.proxyStarting)
340
+ return "proxy starting";
341
+ return "headroom loaded; no compression applied yet";
342
+ }
343
+ function emitNotraceTelemetry(runtime) {
344
+ const state = runtime.state;
345
+ const status = !state.enabled
346
+ ? "loaded-disabled"
347
+ : state.stats.last
348
+ ? "active"
349
+ : "loaded-inactive";
350
+ const details = {
351
+ attempts: state.stats.attempts,
352
+ applied: state.stats.applied,
353
+ guardSkips: state.stats.guardSkips,
354
+ tokensSaved: state.stats.tokensSaved,
355
+ proxyOnline: state.proxyOnline,
356
+ proxyStarting: state.proxyStarting,
357
+ lastSkipReason: state.stats.lastSkipReason,
358
+ lastError: state.stats.lastError,
359
+ };
360
+ if (state.stats.last)
361
+ details.last = { ...state.stats.last };
362
+ try {
363
+ runtime.pi.events.emit(NOTRACE_TELEMETRY_CHANNEL, {
364
+ extension: "noheadroom",
365
+ loaded: true,
366
+ enabled: state.enabled,
367
+ active: Boolean(state.stats.last),
368
+ status,
369
+ summary: summarizeTelemetry(state),
370
+ details,
371
+ });
372
+ }
373
+ catch {
374
+ // Telemetry should never break compression behavior.
375
+ }
376
+ }
273
377
  function announceAppliedCompression(pi, ctx, result, appliedMessages) {
274
378
  const pct = Math.round((1 - result.compressionRatio) * 100);
275
379
  const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
@@ -278,16 +382,21 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages) {
278
382
  // Note: we no longer use pi.appendEntry or pi.sendMessage here because
279
383
  // modifying the session history inside the context event triggers an infinite loop.
280
384
  // Non-interactive `pi -p` does not always show footer/status UI, so print an explicit proof line.
281
- process.stderr.write(`🗜 ${line}\n`);
385
+ // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
386
+ if (!ctx.hasUI)
387
+ process.stderr.write(`🗜 ${line}\n`);
282
388
  }
283
389
  function announceGuardSkip(ctx, reason, result) {
284
390
  const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
285
391
  ctx.ui.notify(line, "warning");
286
- process.stderr.write(`⚠ ${line}\n`);
392
+ // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
393
+ if (!ctx.hasUI)
394
+ process.stderr.write(`⚠ ${line}\n`);
287
395
  }
288
396
  function recordCompressionError(runtime, ctx, error) {
289
397
  runtime.state.stats.lastError = getErrorMessage(error);
290
398
  if (isAbortOrTimeoutError(error)) {
399
+ emitNotraceTelemetry(runtime);
291
400
  runtime.refreshStatus(ctx);
292
401
  return;
293
402
  }
@@ -296,6 +405,7 @@ function recordCompressionError(runtime, ctx, error) {
296
405
  runtime.state.offlineWarningShown = true;
297
406
  ctx.ui.notify(`Headroom proxy unavailable. Compression disabled until /headroom health succeeds.\n${runtime.state.stats.lastError}`, "warning");
298
407
  }
408
+ emitNotraceTelemetry(runtime);
299
409
  runtime.refreshStatus(ctx);
300
410
  }
301
411
  function getErrorMessage(error) {
@@ -319,6 +429,7 @@ async function handleCommand(runtime, command, ctx) {
319
429
  runtime.state.offlineWarningShown = false;
320
430
  runtime.state.proxyStartAttempted = false;
321
431
  const healthy = await runtime.ensureProxy(ctx);
432
+ emitNotraceTelemetry(runtime);
322
433
  ctx.ui.notify(healthy
323
434
  ? "Headroom compression enabled. Proxy will keep running after Pi exits."
324
435
  : proxyStartHint(runtime.config), healthy ? "info" : "warning");
@@ -326,6 +437,7 @@ async function handleCommand(runtime, command, ctx) {
326
437
  }
327
438
  if (command === "off") {
328
439
  runtime.state.enabled = false;
440
+ emitNotraceTelemetry(runtime);
329
441
  runtime.refreshStatus(ctx);
330
442
  ctx.ui.notify("Headroom compression disabled for this Pi session. The proxy process is left running.", "info");
331
443
  return;
@@ -333,6 +445,7 @@ async function handleCommand(runtime, command, ctx) {
333
445
  if (command === "health") {
334
446
  runtime.state.proxyStartAttempted = false;
335
447
  const healthy = await runtime.ensureProxy(ctx);
448
+ emitNotraceTelemetry(runtime);
336
449
  ctx.ui.notify(healthy ? `Headroom proxy online: ${runtime.config.baseUrl}` : proxyStartHint(runtime.config), healthy ? "info" : "warning");
337
450
  return;
338
451
  }
@@ -370,7 +483,7 @@ function renderFooterStatus(ctx, config, state) {
370
483
  if (!state.stats.last)
371
484
  return paint("success", "✓") + paint("dim", " Headroom");
372
485
  const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
373
- return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`));
486
+ return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} sve)`));
374
487
  }
375
488
  async function showProxyStats(ctx, client, config) {
376
489
  if (isRemoteBlocked(config)) {
@@ -28,10 +28,16 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
28
28
  if (!converted) continue;
29
29
 
30
30
  const originalText = extractOpenAIText(converted);
31
- // Allow any toolResult to be a candidate for compression if Headroom decides to shrink it.
32
- // The minMessageChars threshold is primarily to avoid overhead for tiny messages,
33
- // but we shouldn't block Headroom if it finds savings in slightly smaller ones.
34
- const applyTo = source.role === "toolResult" ? "toolResult" : null;
31
+ // Mark candidates for compression.
32
+ // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
33
+ let applyTo: "toolResult" | null = source.role === "toolResult" ? "toolResult" : null;
34
+
35
+ // Headroom Bypass Rules (Android Hat)
36
+ // We never want to compress `android layout` JSON dumps or critical adb dumps.
37
+ if (source.role === "toolResult" && originalText.trim().startsWith("[") && originalText.includes('"resource-id"')) {
38
+ applyTo = null;
39
+ }
40
+
35
41
  if (applyTo && originalText.length >= minMessageChars) candidateCount++;
36
42
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
37
43
  }
@@ -59,22 +65,20 @@ export function applyCompressionResult(
59
65
  for (let index = 0; index < mappings.length; index++) {
60
66
  const mapping = mappings[index];
61
67
  const compressed = compressedMessages[index];
68
+
69
+ // We only validate and apply changes to explicit candidates.
70
+ // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
71
+ // but we simply ignore those changes and keep the original Pi message intact.
72
+ if (!mapping.applyTo) continue;
73
+
62
74
  const validation = validateAlignedMessage(mapping.message, compressed);
63
75
  if (!validation.ok) return validation;
64
76
 
65
- const nextText = extractOpenAIText(compressed);
77
+ let nextText = extractOpenAIText(compressed);
66
78
  if (nextText === mapping.originalText) continue;
67
79
 
68
- if (mapping.applyTo !== "toolResult") {
69
- return { ok: false, reason: `non-candidate-changed:${mapping.message.role}` };
70
- }
71
-
72
80
  const target = nextMessages[mapping.sourceIndex] as AnyMessage;
73
- if (target.role !== "toolResult") {
74
- return { ok: false, reason: "source-role-mismatch" };
75
- }
76
-
77
- if (!replaceTextContent(target, nextText)) {
81
+ if (!hasContent(target) || !replaceTextContent(target, nextText)) {
78
82
  return { ok: false, reason: "target-content-unreplaceable" };
79
83
  }
80
84
  appliedMessages++;
@@ -204,8 +208,26 @@ function replaceTextContent(message: MessageWithContent, text: string): boolean
204
208
  return true;
205
209
  }
206
210
  if (!Array.isArray(message.content)) return false;
207
- const imageParts = message.content.filter((part): part is ImageContent => isImageContent(part));
208
- message.content = [{ type: "text", text }, ...imageParts];
211
+
212
+ const nextContent: unknown[] = [];
213
+ let replacedText = false;
214
+
215
+ for (const part of message.content) {
216
+ if (isTextContent(part)) {
217
+ if (!replacedText) {
218
+ nextContent.push({ type: "text", text });
219
+ replacedText = true;
220
+ }
221
+ continue;
222
+ }
223
+ nextContent.push(part);
224
+ }
225
+
226
+ if (!replacedText && text.length > 0) {
227
+ nextContent.unshift({ type: "text", text });
228
+ }
229
+
230
+ message.content = nextContent as typeof message.content;
209
231
  return true;
210
232
  }
211
233
 
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { Box, Text } from "@earendil-works/pi-tui";
3
4
  import {
@@ -9,10 +10,11 @@ import {
9
10
  import { HeadroomHttpClient } from "./client.js";
10
11
  import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
11
12
  import { startPersistentHeadroomProxy } from "./proxy-manager.js";
12
- import type { AgentMessage, CompressResult, HeadroomConfig, HeadroomStats } from "./types.js";
13
+ import type { AgentMessage, CompressResult, CompressionPayload, HeadroomConfig, HeadroomStats } from "./types.js";
13
14
 
14
15
  const STATUS_KEY = "headroom";
15
16
  const SUBCOMMANDS = ["status", "on", "off", "health", "stats"] as const;
17
+ const NOTRACE_TELEMETRY_CHANNEL = "notrace.telemetry.extension";
16
18
 
17
19
  type Subcommand = (typeof SUBCOMMANDS)[number];
18
20
 
@@ -26,6 +28,7 @@ interface HeadroomRuntimeState {
26
28
  processing: boolean;
27
29
  lastInputFingerprint: string | null;
28
30
  lastOutputFingerprint: string | null;
31
+ lastGuardSkipCandidateFingerprint: string | null;
29
32
  lastCompressionTime: number;
30
33
  stats: HeadroomStats;
31
34
  }
@@ -59,6 +62,7 @@ export default function headroomExtension(pi: ExtensionAPI) {
59
62
  pi.on("session_start", (_event, ctx) => {
60
63
  if (isRemoteBlocked(runtime.config)) {
61
64
  runtime.refreshStatus(ctx);
65
+ emitNotraceTelemetry(runtime);
62
66
  ctx.ui.notify(
63
67
  `Headroom remote URL is blocked by default: ${runtime.config.baseUrl}\nSet PI_HEADROOM_ALLOW_REMOTE=1 only if you trust that proxy with full context.`,
64
68
  "warning",
@@ -66,6 +70,7 @@ export default function headroomExtension(pi: ExtensionAPI) {
66
70
  return;
67
71
  }
68
72
  runtime.refreshStatus(ctx);
73
+ emitNotraceTelemetry(runtime);
69
74
  if (!runtime.state.enabled) return;
70
75
  void ensureProxyInBackground(runtime, ctx);
71
76
  });
@@ -105,6 +110,7 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
105
110
  processing: false,
106
111
  lastInputFingerprint: null,
107
112
  lastOutputFingerprint: null,
113
+ lastGuardSkipCandidateFingerprint: null,
108
114
  lastCompressionTime: 0,
109
115
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
110
116
  };
@@ -258,6 +264,13 @@ async function handleContextCompression(
258
264
  if (shouldSkipBeforePayload(runtime, ctx)) return undefined;
259
265
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
260
266
  if (payload.candidateCount === 0) return undefined;
267
+
268
+ // 3. If eligible candidates haven't changed since the last skip/no-savings result,
269
+ // don't spend another proxy call just because surrounding conversation changed.
270
+ const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
271
+ if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
272
+ return undefined;
273
+ }
261
274
  if (runtime.state.proxyOnline !== true) {
262
275
  void ensureProxyInBackground(runtime, ctx);
263
276
  return undefined;
@@ -270,8 +283,10 @@ async function handleContextCompression(
270
283
  const result = await runtime.client.compress(payload.messages, ctx.model?.id, ctx.signal);
271
284
  runtime.state.proxyOnline = true;
272
285
  if (!result.compressed || result.tokensSaved <= 0) {
273
- // Even if no tokens saved, record the input so we don't keep trying
286
+ // Even if no tokens saved, record fingerprints so we don't keep trying
287
+ // until the actual compressible candidate material changes.
274
288
  runtime.state.lastInputFingerprint = inputFingerprint;
289
+ runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
275
290
  runtime.refreshStatus(ctx);
276
291
  return undefined;
277
292
  }
@@ -280,7 +295,13 @@ async function handleContextCompression(
280
295
  minMessageChars: runtime.config.minMessageChars,
281
296
  });
282
297
  if (!applied.ok) {
298
+ // Record the input even on guard skips to prevent looping retries for this context
299
+ runtime.state.lastInputFingerprint = inputFingerprint;
300
+ runtime.state.lastOutputFingerprint = null;
301
+ runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
302
+
283
303
  recordGuardSkip(runtime.state.stats, applied.reason);
304
+ emitNotraceTelemetry(runtime);
284
305
  announceGuardSkip(ctx, applied.reason, result);
285
306
  runtime.refreshStatus(ctx);
286
307
  return undefined;
@@ -289,8 +310,10 @@ async function handleContextCompression(
289
310
  // Store fingerprints to break the feedback loop
290
311
  runtime.state.lastInputFingerprint = inputFingerprint;
291
312
  runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
313
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
292
314
 
293
315
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
316
+ emitNotraceTelemetry(runtime);
294
317
  announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
295
318
  runtime.refreshStatus(ctx);
296
319
  return { messages: applied.messages };
@@ -302,12 +325,51 @@ async function handleContextCompression(
302
325
  }
303
326
  }
304
327
 
328
+ function generateCandidateFingerprint(messages: AgentMessage[], payload: CompressionPayload): string {
329
+ const units = payload.mappings
330
+ .filter((mapping) => mapping.applyTo)
331
+ .map((mapping) => {
332
+ const source = messages[mapping.sourceIndex] as AgentMessage & {
333
+ content?: unknown;
334
+ toolCallId?: unknown;
335
+ toolName?: unknown;
336
+ };
337
+ return {
338
+ applyTo: mapping.applyTo,
339
+ sourceIndex: mapping.sourceIndex,
340
+ role: source.role,
341
+ toolCallId: typeof source.toolCallId === "string" ? source.toolCallId : null,
342
+ toolName: typeof source.toolName === "string" ? source.toolName : null,
343
+ contentShape: describeContentShape(source.content),
344
+ textLength: mapping.originalText.length,
345
+ textHash: stableHash(mapping.originalText),
346
+ };
347
+ });
348
+ return stableHash(JSON.stringify(units));
349
+ }
350
+
351
+ function describeContentShape(content: unknown): string {
352
+ if (Array.isArray(content)) {
353
+ return content
354
+ .map((part) => {
355
+ if (!part || typeof part !== "object" || !("type" in part)) return "unknown";
356
+ return String((part as { type?: unknown }).type ?? "unknown");
357
+ })
358
+ .join(",");
359
+ }
360
+ return typeof content;
361
+ }
362
+
363
+ function stableHash(value: string): string {
364
+ return createHash("sha256").update(value).digest("hex");
365
+ }
366
+
305
367
  function generateFingerprint(messages: AgentMessage[]): string {
306
368
  return messages
307
369
  .map((m) => {
308
370
  const converted = convertMessage(m);
309
- const len = converted ? extractOpenAIText(converted).length : 0;
310
- return `${m.role}:${len}`;
371
+ const text = converted ? extractOpenAIText(converted) : "";
372
+ return `${m.role}:${text.length}:${stableHash(text)}`;
311
373
  })
312
374
  .join(",");
313
375
  }
@@ -339,6 +401,52 @@ function recordAppliedCompression(stats: HeadroomStats, result: CompressResult,
339
401
  stats.last = { ...result, appliedMessages };
340
402
  }
341
403
 
404
+ function summarizeTelemetry(state: HeadroomRuntimeState): string | null {
405
+ if (state.stats.last) {
406
+ const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
407
+ 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}%)`;
408
+ }
409
+ if (!state.enabled) return "headroom loaded but disabled for this session";
410
+ if (state.stats.lastSkipReason) return `compression not applied; last guard skip: ${state.stats.lastSkipReason}`;
411
+ if (state.stats.lastError) return `compression unavailable; last error: ${state.stats.lastError}`;
412
+ if (state.proxyOnline === false) return "proxy unavailable";
413
+ if (state.proxyStarting) return "proxy starting";
414
+ return "headroom loaded; no compression applied yet";
415
+ }
416
+
417
+ function emitNotraceTelemetry(runtime: HeadroomRuntime): void {
418
+ const state = runtime.state;
419
+ const status = !state.enabled
420
+ ? "loaded-disabled"
421
+ : state.stats.last
422
+ ? "active"
423
+ : "loaded-inactive";
424
+ const details: Record<string, unknown> = {
425
+ attempts: state.stats.attempts,
426
+ applied: state.stats.applied,
427
+ guardSkips: state.stats.guardSkips,
428
+ tokensSaved: state.stats.tokensSaved,
429
+ proxyOnline: state.proxyOnline,
430
+ proxyStarting: state.proxyStarting,
431
+ lastSkipReason: state.stats.lastSkipReason,
432
+ lastError: state.stats.lastError,
433
+ };
434
+ if (state.stats.last) details.last = { ...state.stats.last };
435
+ try {
436
+ runtime.pi.events.emit(NOTRACE_TELEMETRY_CHANNEL, {
437
+ extension: "noheadroom",
438
+ loaded: true,
439
+ enabled: state.enabled,
440
+ active: Boolean(state.stats.last),
441
+ status,
442
+ summary: summarizeTelemetry(state),
443
+ details,
444
+ });
445
+ } catch {
446
+ // Telemetry should never break compression behavior.
447
+ }
448
+ }
449
+
342
450
  function announceAppliedCompression(
343
451
  pi: ExtensionAPI,
344
452
  ctx: ExtensionContext,
@@ -352,18 +460,21 @@ function announceAppliedCompression(
352
460
  // Note: we no longer use pi.appendEntry or pi.sendMessage here because
353
461
  // modifying the session history inside the context event triggers an infinite loop.
354
462
  // Non-interactive `pi -p` does not always show footer/status UI, so print an explicit proof line.
355
- process.stderr.write(`🗜 ${line}\n`);
463
+ // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
464
+ if (!ctx.hasUI) process.stderr.write(`🗜 ${line}\n`);
356
465
  }
357
466
 
358
467
  function announceGuardSkip(ctx: ExtensionContext, reason: string, result: CompressResult): void {
359
468
  const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
360
469
  ctx.ui.notify(line, "warning");
361
- process.stderr.write(`⚠ ${line}\n`);
470
+ // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
471
+ if (!ctx.hasUI) process.stderr.write(`⚠ ${line}\n`);
362
472
  }
363
473
 
364
474
  function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext, error: unknown): void {
365
475
  runtime.state.stats.lastError = getErrorMessage(error);
366
476
  if (isAbortOrTimeoutError(error)) {
477
+ emitNotraceTelemetry(runtime);
367
478
  runtime.refreshStatus(ctx);
368
479
  return;
369
480
  }
@@ -376,6 +487,7 @@ function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext,
376
487
  "warning",
377
488
  );
378
489
  }
490
+ emitNotraceTelemetry(runtime);
379
491
  runtime.refreshStatus(ctx);
380
492
  }
381
493
 
@@ -402,6 +514,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
402
514
  runtime.state.offlineWarningShown = false;
403
515
  runtime.state.proxyStartAttempted = false;
404
516
  const healthy = await runtime.ensureProxy(ctx);
517
+ emitNotraceTelemetry(runtime);
405
518
  ctx.ui.notify(
406
519
  healthy
407
520
  ? "Headroom compression enabled. Proxy will keep running after Pi exits."
@@ -412,6 +525,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
412
525
  }
413
526
  if (command === "off") {
414
527
  runtime.state.enabled = false;
528
+ emitNotraceTelemetry(runtime);
415
529
  runtime.refreshStatus(ctx);
416
530
  ctx.ui.notify("Headroom compression disabled for this Pi session. The proxy process is left running.", "info");
417
531
  return;
@@ -419,6 +533,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
419
533
  if (command === "health") {
420
534
  runtime.state.proxyStartAttempted = false;
421
535
  const healthy = await runtime.ensureProxy(ctx);
536
+ emitNotraceTelemetry(runtime);
422
537
  ctx.ui.notify(
423
538
  healthy ? `Headroom proxy online: ${runtime.config.baseUrl}` : proxyStartHint(runtime.config),
424
539
  healthy ? "info" : "warning",
@@ -463,7 +578,7 @@ function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state
463
578
 
464
579
  const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
465
580
  return (
466
- paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`)
581
+ paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} sve)`)
467
582
  );
468
583
  }
469
584
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.2.1",
3
+ "version": "0.2.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",