@raquezha/noheadroom 0.2.1 → 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 +4 -1
- package/dist/bridge.js +31 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +110 -1
- package/extensions/bridge.ts +38 -16
- package/extensions/index.ts +115 -2
- 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,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
|
}
|
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,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