@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 +4 -1
- package/dist/bridge.js +31 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +119 -6
- package/extensions/bridge.ts +38 -16
- package/extensions/index.ts +122 -7
- package/package.json +1 -1
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,
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
178
|
-
|
|
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
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
|
|
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
|
|
244
|
-
return `${m.role}:${
|
|
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
|
-
|
|
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
|
-
|
|
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()}
|
|
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)) {
|
package/extensions/bridge.ts
CHANGED
|
@@ -28,10 +28,16 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
|
|
|
28
28
|
if (!converted) continue;
|
|
29
29
|
|
|
30
30
|
const originalText = extractOpenAIText(converted);
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
310
|
-
return `${m.role}:${
|
|
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
|
-
|
|
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
|
-
|
|
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()}
|
|
581
|
+
paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} sve)`)
|
|
467
582
|
);
|
|
468
583
|
}
|
|
469
584
|
|
package/package.json
CHANGED