@raquezha/noheadroom 0.2.0 → 0.2.2

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,6 +256,39 @@ 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) => {
@@ -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})`;
@@ -288,6 +392,7 @@ function announceGuardSkip(ctx, reason, result) {
288
392
  function recordCompressionError(runtime, ctx, error) {
289
393
  runtime.state.stats.lastError = getErrorMessage(error);
290
394
  if (isAbortOrTimeoutError(error)) {
395
+ emitNotraceTelemetry(runtime);
291
396
  runtime.refreshStatus(ctx);
292
397
  return;
293
398
  }
@@ -296,6 +401,7 @@ function recordCompressionError(runtime, ctx, error) {
296
401
  runtime.state.offlineWarningShown = true;
297
402
  ctx.ui.notify(`Headroom proxy unavailable. Compression disabled until /headroom health succeeds.\n${runtime.state.stats.lastError}`, "warning");
298
403
  }
404
+ emitNotraceTelemetry(runtime);
299
405
  runtime.refreshStatus(ctx);
300
406
  }
301
407
  function getErrorMessage(error) {
@@ -319,6 +425,7 @@ async function handleCommand(runtime, command, ctx) {
319
425
  runtime.state.offlineWarningShown = false;
320
426
  runtime.state.proxyStartAttempted = false;
321
427
  const healthy = await runtime.ensureProxy(ctx);
428
+ emitNotraceTelemetry(runtime);
322
429
  ctx.ui.notify(healthy
323
430
  ? "Headroom compression enabled. Proxy will keep running after Pi exits."
324
431
  : proxyStartHint(runtime.config), healthy ? "info" : "warning");
@@ -326,6 +433,7 @@ async function handleCommand(runtime, command, ctx) {
326
433
  }
327
434
  if (command === "off") {
328
435
  runtime.state.enabled = false;
436
+ emitNotraceTelemetry(runtime);
329
437
  runtime.refreshStatus(ctx);
330
438
  ctx.ui.notify("Headroom compression disabled for this Pi session. The proxy process is left running.", "info");
331
439
  return;
@@ -333,6 +441,7 @@ async function handleCommand(runtime, command, ctx) {
333
441
  if (command === "health") {
334
442
  runtime.state.proxyStartAttempted = false;
335
443
  const healthy = await runtime.ensureProxy(ctx);
444
+ emitNotraceTelemetry(runtime);
336
445
  ctx.ui.notify(healthy ? `Headroom proxy online: ${runtime.config.baseUrl}` : proxyStartHint(runtime.config), healthy ? "info" : "warning");
337
446
  return;
338
447
  }
@@ -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,6 +325,45 @@ 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) => {
@@ -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,
@@ -364,6 +472,7 @@ function announceGuardSkip(ctx: ExtensionContext, reason: string, result: Compre
364
472
  function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext, error: unknown): void {
365
473
  runtime.state.stats.lastError = getErrorMessage(error);
366
474
  if (isAbortOrTimeoutError(error)) {
475
+ emitNotraceTelemetry(runtime);
367
476
  runtime.refreshStatus(ctx);
368
477
  return;
369
478
  }
@@ -376,6 +485,7 @@ function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext,
376
485
  "warning",
377
486
  );
378
487
  }
488
+ emitNotraceTelemetry(runtime);
379
489
  runtime.refreshStatus(ctx);
380
490
  }
381
491
 
@@ -402,6 +512,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
402
512
  runtime.state.offlineWarningShown = false;
403
513
  runtime.state.proxyStartAttempted = false;
404
514
  const healthy = await runtime.ensureProxy(ctx);
515
+ emitNotraceTelemetry(runtime);
405
516
  ctx.ui.notify(
406
517
  healthy
407
518
  ? "Headroom compression enabled. Proxy will keep running after Pi exits."
@@ -412,6 +523,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
412
523
  }
413
524
  if (command === "off") {
414
525
  runtime.state.enabled = false;
526
+ emitNotraceTelemetry(runtime);
415
527
  runtime.refreshStatus(ctx);
416
528
  ctx.ui.notify("Headroom compression disabled for this Pi session. The proxy process is left running.", "info");
417
529
  return;
@@ -419,6 +531,7 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
419
531
  if (command === "health") {
420
532
  runtime.state.proxyStartAttempted = false;
421
533
  const healthy = await runtime.ensureProxy(ctx);
534
+ emitNotraceTelemetry(runtime);
422
535
  ctx.ui.notify(
423
536
  healthy ? `Headroom proxy online: ${runtime.config.baseUrl}` : proxyStartHint(runtime.config),
424
537
  healthy ? "info" : "warning",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",