@raquezha/noheadroom 0.3.2 → 0.3.4
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 +6 -13
- package/dist/bridge.d.ts +1 -2
- package/dist/bridge.js +28 -33
- package/dist/client.d.ts +2 -0
- package/dist/client.js +29 -6
- package/dist/config.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +70 -19
- package/dist/proxy-manager.js +11 -5
- package/extensions/bridge.ts +25 -40
- package/extensions/client.ts +29 -5
- package/extensions/config.ts +3 -2
- package/extensions/index.ts +70 -23
- package/extensions/proxy-manager.ts +11 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,28 +1,21 @@
|
|
|
1
1
|
# noheadroom 🗜
|
|
2
2
|
|
|
3
|
-
> **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/
|
|
3
|
+
> **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroomlabs-ai/headroom).
|
|
4
4
|
|
|
5
5
|
`noheadroom` sits between Pi and your LLM, using Headroom to shrink massive tool outputs/results and logs before they become model input. Headroom itself is a general prompt/context compression engine that can transform broader request material. `noheadroom` deliberately applies a stricter Pi policy: user prompts and assistant messages may be sent as context for the compression request, but Pi only accepts mutations to `toolResult` content. User chat, assistant text, tool-call metadata, and tool IDs remain unchanged in real Pi history. Save tokens, keep more history, and prevent context-overflow in long sessions.
|
|
6
6
|
|
|
7
7
|
## 🚀 Why noheadroom?
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`noheadroom` adapts the compression payload to bypass these exclusions while **fully preserving your Pi session metadata**.
|
|
12
|
-
|
|
13
|
-
| Setup | Tool Name | Headroom Action | Savings |
|
|
14
|
-
|---|---|---|---|
|
|
15
|
-
| Vanilla | `read` | `excluded_tool` | 0% |
|
|
16
|
-
| **noheadroom** | `read` | `smart_crusher` | **60-90%** |
|
|
9
|
+
Headroom intentionally protects file reads: changing source text can break exact-match edits. `noheadroom` keeps those reads raw and compresses eligible large text-only tool results (for example, repetitive logs). It never changes the stored Pi session transcript.
|
|
17
10
|
|
|
18
11
|
## ✨ Features
|
|
19
12
|
|
|
20
13
|
- **Headroom Bridge, Pi Policy**: Headroom can optimize broad prompt/context payloads; `noheadroom` intentionally narrows what gets applied back to Pi so only `toolResult` content mutates.
|
|
21
|
-
- **
|
|
22
|
-
- **Strict Candidate Isolation**:
|
|
23
|
-
- **
|
|
14
|
+
- **Tool Fidelity**: preserves tool names and arguments so Headroom's protected-tool policy remains effective.
|
|
15
|
+
- **Strict Candidate Isolation**: applies only eligible text-only, non-error `toolResult` changes; rejects retrieval markers without a registered retrieval tool.
|
|
16
|
+
- **Request-local Replay**: reuses validated compression for the same tool call and unchanged result on later model requests without re-calling the proxy. A new tool call with the same output remains raw to avoid re-read loops.
|
|
24
17
|
- **Pi-Native Metadata Preservation**: original tool IDs and names are never modified in your real session.
|
|
25
|
-
- **
|
|
18
|
+
- **Visibility**: session-local compression estimates appear in the terminal and Pi footer; proxy `/stats` does not count extension `/v1/compress` requests as inference savings.
|
|
26
19
|
- **Docker-First Architecture**: designed to work seamlessly with a local containerized backend.
|
|
27
20
|
- **Local-First Privacy**: by default, context never leaves your machine.
|
|
28
21
|
|
package/dist/bridge.d.ts
CHANGED
|
@@ -5,8 +5,7 @@ type AnyMessage = AgentMessage & {
|
|
|
5
5
|
timestamp?: number;
|
|
6
6
|
};
|
|
7
7
|
export declare function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload;
|
|
8
|
-
export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[],
|
|
8
|
+
export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], options: ApplyCompressionOptions): ApplyCompressionResult;
|
|
9
9
|
export declare function convertMessage(message: AnyMessage): OpenAIMessage | undefined;
|
|
10
10
|
export declare function extractOpenAIText(message: OpenAIMessage): string;
|
|
11
|
-
export declare function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[];
|
|
12
11
|
export {};
|
package/dist/bridge.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
2
|
+
// File reads supply exact edit anchors; directives and recovered evidence must stay raw.
|
|
3
|
+
const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
|
|
4
|
+
const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
|
|
2
5
|
export function buildCompressionPayload(messages, minMessageChars) {
|
|
3
6
|
const mappings = [];
|
|
4
7
|
let candidateCount = 0;
|
|
@@ -10,13 +13,19 @@ export function buildCompressionPayload(messages, minMessageChars) {
|
|
|
10
13
|
const originalText = extractOpenAIText(converted);
|
|
11
14
|
// Mark candidates for compression.
|
|
12
15
|
// ONLY toolResults are candidates, preserving original Pi conversation fidelity.
|
|
13
|
-
|
|
16
|
+
const textOnly = typeof source.content === "string" ||
|
|
17
|
+
(Array.isArray(source.content) && source.content.every(isTextContent));
|
|
18
|
+
let applyTo = source.role === "toolResult" &&
|
|
19
|
+
textOnly && originalText.length >= minMessageChars &&
|
|
20
|
+
!("isError" in source && source.isError) &&
|
|
21
|
+
!PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
|
|
22
|
+
? "toolResult" : null;
|
|
14
23
|
// Headroom Bypass Rules (Android Hat)
|
|
15
24
|
// We never want to compress `android layout` JSON dumps or critical adb dumps.
|
|
16
25
|
if (source.role === "toolResult" && originalText.trim().startsWith("[") && originalText.includes('"resource-id"')) {
|
|
17
26
|
applyTo = null;
|
|
18
27
|
}
|
|
19
|
-
if (applyTo
|
|
28
|
+
if (applyTo)
|
|
20
29
|
candidateCount++;
|
|
21
30
|
mappings.push({ sourceIndex, message: converted, applyTo, originalText });
|
|
22
31
|
}
|
|
@@ -26,8 +35,8 @@ export function buildCompressionPayload(messages, minMessageChars) {
|
|
|
26
35
|
candidateCount,
|
|
27
36
|
};
|
|
28
37
|
}
|
|
29
|
-
export function applyCompressionResult(originalMessages, mappings, compressedMessages,
|
|
30
|
-
if (compressedMessages.length !== mappings.length) {
|
|
38
|
+
export function applyCompressionResult(originalMessages, mappings, compressedMessages, options) {
|
|
39
|
+
if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
|
|
31
40
|
return { ok: false, reason: "message-count-changed" };
|
|
32
41
|
}
|
|
33
42
|
const nextMessages = structuredClone(originalMessages);
|
|
@@ -40,16 +49,21 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
|
|
|
40
49
|
// We only validate and apply changes to explicit candidates.
|
|
41
50
|
// Headroom is allowed to mangle non-candidates (like assistant history) in its output,
|
|
42
51
|
// but we simply ignore those changes and keep the original Pi message intact.
|
|
43
|
-
if (!mapping.applyTo)
|
|
52
|
+
if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars)
|
|
44
53
|
continue;
|
|
45
54
|
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
46
55
|
if (!validation.ok)
|
|
47
56
|
return validation;
|
|
48
|
-
|
|
57
|
+
const nextText = extractOpenAIText(compressed);
|
|
49
58
|
if (nextText === mapping.originalText)
|
|
50
59
|
continue;
|
|
51
|
-
//
|
|
52
|
-
|
|
60
|
+
// No retrieval executor is registered. Hiding a hash does not recover evidence.
|
|
61
|
+
if (CCR_MARKER.test(nextText))
|
|
62
|
+
return { ok: false, reason: "unrecoverable-ccr-marker" };
|
|
63
|
+
if (!nextText.trim())
|
|
64
|
+
return { ok: false, reason: "empty-compressed-content" };
|
|
65
|
+
if (estimateTokens(nextText) >= estimateTokens(mapping.originalText))
|
|
66
|
+
continue;
|
|
53
67
|
const target = nextMessages[mapping.sourceIndex];
|
|
54
68
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
55
69
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
@@ -142,14 +156,16 @@ function convertToolCall(toolCall) {
|
|
|
142
156
|
id: toolCall.id,
|
|
143
157
|
type: "function",
|
|
144
158
|
function: {
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
159
|
+
// Preserve upstream tool protections as well as routing context.
|
|
160
|
+
name: toolCall.name,
|
|
161
|
+
arguments: JSON.stringify(toolCall.arguments),
|
|
149
162
|
},
|
|
150
163
|
};
|
|
151
164
|
}
|
|
152
165
|
function validateAlignedMessage(original, compressed) {
|
|
166
|
+
if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
|
|
167
|
+
return { ok: false, reason: "invalid-compressed-message" };
|
|
168
|
+
}
|
|
153
169
|
if (original.role !== compressed.role) {
|
|
154
170
|
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
155
171
|
}
|
|
@@ -241,24 +257,3 @@ function estimateTokens(text) {
|
|
|
241
257
|
// ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
|
|
242
258
|
return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
|
|
243
259
|
}
|
|
244
|
-
function naturalizeHeadroomMarkers(text) {
|
|
245
|
-
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
246
|
-
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
247
|
-
return text.replace(/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi, "[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]");
|
|
248
|
-
}
|
|
249
|
-
export function injectCompressionAwareness(messages) {
|
|
250
|
-
// If a system message already exists at the top, we append the hint to it.
|
|
251
|
-
// Otherwise, we inject a new system message at index 0.
|
|
252
|
-
const result = [...messages];
|
|
253
|
-
const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
|
|
254
|
-
const first = result[0];
|
|
255
|
-
if (first && first.role === "system" && typeof first.content === "string") {
|
|
256
|
-
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
257
|
-
result[0] = { ...first, content: `${first.content}\n\n${hintText}` };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
result.unshift({ role: "system", content: hintText });
|
|
262
|
-
}
|
|
263
|
-
return result;
|
|
264
|
-
}
|
package/dist/client.d.ts
CHANGED
package/dist/client.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export class HeadroomResponseError extends Error {
|
|
2
|
+
}
|
|
1
3
|
export class HeadroomHttpClient {
|
|
2
4
|
baseUrl;
|
|
3
5
|
timeoutMs;
|
|
@@ -8,14 +10,13 @@ export class HeadroomHttpClient {
|
|
|
8
10
|
async health(signal) {
|
|
9
11
|
try {
|
|
10
12
|
const response = await fetch(`${this.baseUrl}/health`, {
|
|
13
|
+
redirect: "error",
|
|
11
14
|
signal: buildSignal(this.timeoutMs, signal),
|
|
12
15
|
});
|
|
13
16
|
if (!response.ok)
|
|
14
17
|
return false;
|
|
15
18
|
const body = await readJsonObject(response);
|
|
16
|
-
|
|
17
|
-
return true;
|
|
18
|
-
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
19
|
+
return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
|
|
19
20
|
}
|
|
20
21
|
catch {
|
|
21
22
|
return false;
|
|
@@ -24,12 +25,16 @@ export class HeadroomHttpClient {
|
|
|
24
25
|
async probe(signal) {
|
|
25
26
|
try {
|
|
26
27
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
28
|
+
redirect: "error",
|
|
27
29
|
method: "POST",
|
|
28
30
|
headers: { "Content-Type": "application/json" },
|
|
29
31
|
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
|
|
30
32
|
signal: buildSignal(5_000, signal),
|
|
31
33
|
});
|
|
32
|
-
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
return false;
|
|
36
|
+
validateCompressionResponse(await response.json());
|
|
37
|
+
return true;
|
|
33
38
|
}
|
|
34
39
|
catch {
|
|
35
40
|
return false;
|
|
@@ -37,6 +42,7 @@ export class HeadroomHttpClient {
|
|
|
37
42
|
}
|
|
38
43
|
async stats(signal) {
|
|
39
44
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
45
|
+
redirect: "error",
|
|
40
46
|
signal: buildSignal(this.timeoutMs, signal),
|
|
41
47
|
});
|
|
42
48
|
if (!response.ok) {
|
|
@@ -46,19 +52,25 @@ export class HeadroomHttpClient {
|
|
|
46
52
|
}
|
|
47
53
|
async compress(messages, model, signal) {
|
|
48
54
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
55
|
+
redirect: "error",
|
|
49
56
|
method: "POST",
|
|
50
57
|
headers: {
|
|
51
58
|
"Content-Type": "application/json",
|
|
52
59
|
"X-Headroom-Stack": "pi-extension",
|
|
53
60
|
},
|
|
54
|
-
body: JSON.stringify({
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
messages,
|
|
63
|
+
model: model || "gpt-4o",
|
|
64
|
+
// 0.39.1's default is marker-free and inherits the backend's --lossless policy.
|
|
65
|
+
// lossy_inline would override that policy and enable lossy SmartCrusher.
|
|
66
|
+
}),
|
|
55
67
|
signal: buildSignal(this.timeoutMs, signal),
|
|
56
68
|
});
|
|
57
69
|
if (!response.ok) {
|
|
58
70
|
const message = await readErrorMessage(response);
|
|
59
71
|
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
60
72
|
}
|
|
61
|
-
const payload = (await response.json());
|
|
73
|
+
const payload = validateCompressionResponse(await response.json());
|
|
62
74
|
return {
|
|
63
75
|
messages: payload.messages,
|
|
64
76
|
tokensBefore: payload.tokens_before,
|
|
@@ -71,6 +83,17 @@ export class HeadroomHttpClient {
|
|
|
71
83
|
};
|
|
72
84
|
}
|
|
73
85
|
}
|
|
86
|
+
function validateCompressionResponse(value) {
|
|
87
|
+
const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
|
|
88
|
+
const stringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
89
|
+
if (!isRecord(value) || !Array.isArray(value.messages) ||
|
|
90
|
+
!metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
|
|
91
|
+
(value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
|
|
92
|
+
(value.ccr_hashes != null && !stringArray(value.ccr_hashes))) {
|
|
93
|
+
throw new HeadroomResponseError("Invalid Headroom compression response");
|
|
94
|
+
}
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
74
97
|
async function readJsonObject(response) {
|
|
75
98
|
try {
|
|
76
99
|
const body = (await response.json());
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
5
|
-
const DEFAULT_MIN_CONTEXT_TOKENS =
|
|
5
|
+
const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
|
|
6
6
|
const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
|
|
7
7
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
8
|
export const HEADROOM_SETTINGS_DIR = path.join(os.homedir(), ".pi", "agent", "headroom");
|
|
@@ -48,7 +48,8 @@ export function loadHeadroomConfig(env = process.env, settings = env === process
|
|
|
48
48
|
export function isLocalHeadroomUrl(rawUrl) {
|
|
49
49
|
try {
|
|
50
50
|
const url = new URL(rawUrl);
|
|
51
|
-
return ["
|
|
51
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
|
|
52
|
+
["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
52
53
|
}
|
|
53
54
|
catch {
|
|
54
55
|
return false;
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface HeadroomRuntimeState {
|
|
|
14
14
|
lastGuardSkipCandidateFingerprint: string | null;
|
|
15
15
|
seenCandidateContentFingerprints?: Set<string>;
|
|
16
16
|
seenCandidateContentOrder?: string[];
|
|
17
|
+
compressedCandidates?: Map<string, string>;
|
|
17
18
|
lastCompressionTime: number;
|
|
18
19
|
stats: HeadroomStats;
|
|
19
20
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { applyCompressionResult, buildCompressionPayload, convertMessage, extractOpenAIText, } from "./bridge.js";
|
|
4
|
-
import { HeadroomHttpClient } from "./client.js";
|
|
4
|
+
import { HeadroomHttpClient, HeadroomResponseError } from "./client.js";
|
|
5
5
|
import { isRemoteBlocked, loadHeadroomConfig, saveHeadroomSettings } from "./config.js";
|
|
6
6
|
import { startPersistentHeadroomProxy } from "./proxy-manager.js";
|
|
7
7
|
const STATUS_KEY = "headroom";
|
|
@@ -17,6 +17,14 @@ export default function headroomExtension(pi) {
|
|
|
17
17
|
registerCompressionRenderer(pi);
|
|
18
18
|
const runtime = createRuntime(pi);
|
|
19
19
|
pi.on("session_start", (_event, ctx) => {
|
|
20
|
+
// A context transform is request-local; cached substitutions must not cross sessions.
|
|
21
|
+
runtime.state.compressedCandidates = new Map();
|
|
22
|
+
runtime.state.seenCandidateContentFingerprints = new Set();
|
|
23
|
+
runtime.state.seenCandidateContentOrder = [];
|
|
24
|
+
runtime.state.lastInputFingerprint = null;
|
|
25
|
+
runtime.state.lastOutputFingerprint = null;
|
|
26
|
+
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
27
|
+
runtime.state.lastCompressionTime = 0;
|
|
20
28
|
if (isRemoteBlocked(runtime.config)) {
|
|
21
29
|
runtime.refreshStatus(ctx);
|
|
22
30
|
emitNotraceTelemetry(runtime);
|
|
@@ -79,6 +87,7 @@ function createRuntime(pi) {
|
|
|
79
87
|
lastGuardSkipCandidateFingerprint: null,
|
|
80
88
|
seenCandidateContentFingerprints: new Set(),
|
|
81
89
|
seenCandidateContentOrder: [],
|
|
90
|
+
compressedCandidates: new Map(),
|
|
82
91
|
lastCompressionTime: 0,
|
|
83
92
|
stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
|
|
84
93
|
};
|
|
@@ -207,33 +216,29 @@ function registerCompressionRenderer(pi) {
|
|
|
207
216
|
async function handleContextCompression(runtime, event, ctx) {
|
|
208
217
|
if (runtime.state.processing)
|
|
209
218
|
return undefined;
|
|
219
|
+
let replayed;
|
|
210
220
|
const bypassUncompressed = () => {
|
|
211
221
|
if (runtime.state.stats.last) {
|
|
212
222
|
runtime.state.stats.last = undefined;
|
|
213
223
|
runtime.refreshStatus(ctx);
|
|
214
224
|
}
|
|
215
|
-
return undefined;
|
|
225
|
+
return replayed ? { messages: replayed } : undefined;
|
|
216
226
|
};
|
|
217
|
-
// Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
if (now - runtime.state.lastCompressionTime < 3000) {
|
|
220
|
-
return bypassUncompressed();
|
|
221
|
-
}
|
|
222
|
-
// Content-based guards to prevent infinite recursion
|
|
223
|
-
const inputFingerprint = generateFingerprint(event.messages);
|
|
224
|
-
// 1. If this input matches our previous output, we already compressed it. Stop.
|
|
225
|
-
if (runtime.state.lastOutputFingerprint === inputFingerprint) {
|
|
226
|
-
return undefined;
|
|
227
|
-
}
|
|
228
|
-
// 2. If this input matches our previous input, it didn't change. Stop.
|
|
229
|
-
if (runtime.state.lastInputFingerprint === inputFingerprint) {
|
|
230
|
-
return bypassUncompressed();
|
|
231
|
-
}
|
|
232
227
|
if (shouldSkipBeforePayload(runtime, ctx))
|
|
233
228
|
return bypassUncompressed();
|
|
234
229
|
const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
|
|
230
|
+
replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
|
|
235
231
|
if (payload.candidateCount === 0)
|
|
236
232
|
return bypassUncompressed();
|
|
233
|
+
// Pi's context transform is request-local; reuse validated text on later turns.
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
if (now - runtime.state.lastCompressionTime < 3000)
|
|
236
|
+
return bypassUncompressed();
|
|
237
|
+
const inputFingerprint = generateFingerprint(event.messages);
|
|
238
|
+
if (runtime.state.lastOutputFingerprint === inputFingerprint)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (runtime.state.lastInputFingerprint === inputFingerprint)
|
|
241
|
+
return bypassUncompressed();
|
|
237
242
|
// 3. If eligible candidates haven't changed since the last skip/no-savings result,
|
|
238
243
|
// don't spend another proxy call just because surrounding conversation changed.
|
|
239
244
|
const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
|
|
@@ -276,9 +281,17 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
276
281
|
}
|
|
277
282
|
// Store fingerprints to break the feedback loop
|
|
278
283
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
279
|
-
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
280
284
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
281
285
|
recordSeenCandidateContent(runtime.state, payload, applied.messages);
|
|
286
|
+
if (replayed) {
|
|
287
|
+
for (const mapping of payload.mappings) {
|
|
288
|
+
if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
|
|
289
|
+
applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
recordCachedCandidates(runtime.state, payload, applied.messages);
|
|
294
|
+
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
282
295
|
const appliedResult = {
|
|
283
296
|
...result,
|
|
284
297
|
tokensBefore: applied.appliedTokensBefore,
|
|
@@ -300,6 +313,43 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
300
313
|
runtime.state.processing = false;
|
|
301
314
|
}
|
|
302
315
|
}
|
|
316
|
+
function candidateKey(message, text) {
|
|
317
|
+
const source = message;
|
|
318
|
+
return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
|
|
319
|
+
}
|
|
320
|
+
function replayCachedCandidates(state, messages, payload, minMessageChars) {
|
|
321
|
+
if (!state.compressedCandidates?.size)
|
|
322
|
+
return undefined;
|
|
323
|
+
const mappings = payload.mappings.map((mapping) => ({
|
|
324
|
+
...mapping,
|
|
325
|
+
applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
|
|
326
|
+
? mapping.applyTo : null,
|
|
327
|
+
}));
|
|
328
|
+
const compressed = mappings.map((mapping) => {
|
|
329
|
+
const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
|
|
330
|
+
return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
|
|
331
|
+
});
|
|
332
|
+
const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
|
|
333
|
+
return applied.ok ? applied.messages : undefined;
|
|
334
|
+
}
|
|
335
|
+
function recordCachedCandidates(state, payload, messages) {
|
|
336
|
+
state.compressedCandidates ??= new Map();
|
|
337
|
+
for (const mapping of payload.mappings) {
|
|
338
|
+
if (!mapping.applyTo)
|
|
339
|
+
continue;
|
|
340
|
+
const applied = convertMessage(messages[mapping.sourceIndex]);
|
|
341
|
+
if (!applied || applied.role !== "tool")
|
|
342
|
+
continue;
|
|
343
|
+
const text = extractOpenAIText(applied);
|
|
344
|
+
if (text === mapping.originalText)
|
|
345
|
+
continue;
|
|
346
|
+
const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
|
|
347
|
+
state.compressedCandidates.set(key, text);
|
|
348
|
+
if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
|
|
349
|
+
state.compressedCandidates.delete(state.compressedCandidates.keys().next().value);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
303
353
|
function allCandidateContentSeen(state, payload) {
|
|
304
354
|
const seen = state.seenCandidateContentFingerprints;
|
|
305
355
|
if (!seen?.size)
|
|
@@ -484,7 +534,8 @@ function announceGuardSkip(ctx, reason, result, mode = "normal") {
|
|
|
484
534
|
}
|
|
485
535
|
function recordCompressionError(runtime, ctx, error) {
|
|
486
536
|
runtime.state.stats.lastError = getErrorMessage(error);
|
|
487
|
-
if (isAbortOrTimeoutError(error)) {
|
|
537
|
+
if (isAbortOrTimeoutError(error) || error instanceof HeadroomResponseError) {
|
|
538
|
+
// The proxy answered; invalid data is not a connectivity failure.
|
|
488
539
|
emitNotraceTelemetry(runtime);
|
|
489
540
|
runtime.refreshStatus(ctx);
|
|
490
541
|
return;
|
package/dist/proxy-manager.js
CHANGED
|
@@ -13,27 +13,33 @@ export async function startPersistentHeadroomProxy(config) {
|
|
|
13
13
|
env: {
|
|
14
14
|
...process.env,
|
|
15
15
|
HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
|
|
16
|
+
HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
|
|
16
17
|
},
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
return await new Promise((resolve) => {
|
|
20
|
+
child.once("error", (error) => resolve({ ok: false, reason: error.message }));
|
|
21
|
+
child.once("spawn", () => {
|
|
22
|
+
child.unref();
|
|
23
|
+
resolve({ ok: true });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
20
26
|
}
|
|
21
27
|
catch (error) {
|
|
22
28
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
export function buildProxyArgs(endpoint) {
|
|
26
|
-
return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "
|
|
32
|
+
return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
|
|
27
33
|
}
|
|
28
34
|
export function parseLocalEndpoint(baseUrl) {
|
|
29
35
|
try {
|
|
30
36
|
const url = new URL(baseUrl);
|
|
31
|
-
if (
|
|
37
|
+
if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
32
38
|
return undefined;
|
|
33
39
|
if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname))
|
|
34
40
|
return undefined;
|
|
35
41
|
const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
|
|
36
|
-
const port = url.port || "
|
|
42
|
+
const port = url.port || "80";
|
|
37
43
|
return { host, port };
|
|
38
44
|
}
|
|
39
45
|
catch {
|
package/extensions/bridge.ts
CHANGED
|
@@ -17,6 +17,9 @@ interface MessageWithContent {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
20
|
+
// File reads supply exact edit anchors; directives and recovered evidence must stay raw.
|
|
21
|
+
const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
|
|
22
|
+
const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
|
|
20
23
|
|
|
21
24
|
export function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload {
|
|
22
25
|
const mappings: CompressionMapping[] = [];
|
|
@@ -30,7 +33,13 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
|
|
|
30
33
|
const originalText = extractOpenAIText(converted);
|
|
31
34
|
// Mark candidates for compression.
|
|
32
35
|
// ONLY toolResults are candidates, preserving original Pi conversation fidelity.
|
|
33
|
-
|
|
36
|
+
const textOnly = typeof source.content === "string" ||
|
|
37
|
+
(Array.isArray(source.content) && source.content.every(isTextContent));
|
|
38
|
+
let applyTo: "toolResult" | null = source.role === "toolResult" &&
|
|
39
|
+
textOnly && originalText.length >= minMessageChars &&
|
|
40
|
+
!("isError" in source && source.isError) &&
|
|
41
|
+
!PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
|
|
42
|
+
? "toolResult" : null;
|
|
34
43
|
|
|
35
44
|
// Headroom Bypass Rules (Android Hat)
|
|
36
45
|
// We never want to compress `android layout` JSON dumps or critical adb dumps.
|
|
@@ -38,7 +47,7 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
|
|
|
38
47
|
applyTo = null;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
if (applyTo
|
|
50
|
+
if (applyTo) candidateCount++;
|
|
42
51
|
mappings.push({ sourceIndex, message: converted, applyTo, originalText });
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -53,9 +62,9 @@ export function applyCompressionResult(
|
|
|
53
62
|
originalMessages: AgentMessage[],
|
|
54
63
|
mappings: CompressionMapping[],
|
|
55
64
|
compressedMessages: OpenAIMessage[],
|
|
56
|
-
|
|
65
|
+
options: ApplyCompressionOptions,
|
|
57
66
|
): ApplyCompressionResult {
|
|
58
|
-
if (compressedMessages.length !== mappings.length) {
|
|
67
|
+
if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
|
|
59
68
|
return { ok: false, reason: "message-count-changed" };
|
|
60
69
|
}
|
|
61
70
|
|
|
@@ -71,16 +80,17 @@ export function applyCompressionResult(
|
|
|
71
80
|
// We only validate and apply changes to explicit candidates.
|
|
72
81
|
// Headroom is allowed to mangle non-candidates (like assistant history) in its output,
|
|
73
82
|
// but we simply ignore those changes and keep the original Pi message intact.
|
|
74
|
-
if (!mapping.applyTo) continue;
|
|
83
|
+
if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars) continue;
|
|
75
84
|
|
|
76
85
|
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
77
86
|
if (!validation.ok) return validation;
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
const nextText = extractOpenAIText(compressed);
|
|
80
89
|
if (nextText === mapping.originalText) continue;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
// No retrieval executor is registered. Hiding a hash does not recover evidence.
|
|
91
|
+
if (CCR_MARKER.test(nextText)) return { ok: false, reason: "unrecoverable-ccr-marker" };
|
|
92
|
+
if (!nextText.trim()) return { ok: false, reason: "empty-compressed-content" };
|
|
93
|
+
if (estimateTokens(nextText) >= estimateTokens(mapping.originalText)) continue;
|
|
84
94
|
|
|
85
95
|
const target = nextMessages[mapping.sourceIndex] as AnyMessage;
|
|
86
96
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
@@ -175,10 +185,9 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
|
|
|
175
185
|
id: toolCall.id,
|
|
176
186
|
type: "function",
|
|
177
187
|
function: {
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
188
|
+
// Preserve upstream tool protections as well as routing context.
|
|
189
|
+
name: toolCall.name,
|
|
190
|
+
arguments: JSON.stringify(toolCall.arguments),
|
|
182
191
|
},
|
|
183
192
|
};
|
|
184
193
|
}
|
|
@@ -187,6 +196,9 @@ function validateAlignedMessage(
|
|
|
187
196
|
original: OpenAIMessage,
|
|
188
197
|
compressed: OpenAIMessage,
|
|
189
198
|
): { ok: true } | { ok: false; reason: string } {
|
|
199
|
+
if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
|
|
200
|
+
return { ok: false, reason: "invalid-compressed-message" };
|
|
201
|
+
}
|
|
190
202
|
if (original.role !== compressed.role) {
|
|
191
203
|
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
192
204
|
}
|
|
@@ -287,30 +299,3 @@ function estimateTokens(text: string): number {
|
|
|
287
299
|
// ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
|
|
288
300
|
return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
|
|
289
301
|
}
|
|
290
|
-
|
|
291
|
-
function naturalizeHeadroomMarkers(text: string): string {
|
|
292
|
-
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
293
|
-
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
294
|
-
return text.replace(
|
|
295
|
-
/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi,
|
|
296
|
-
"[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]"
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[] {
|
|
301
|
-
// If a system message already exists at the top, we append the hint to it.
|
|
302
|
-
// Otherwise, we inject a new system message at index 0.
|
|
303
|
-
const result = [...messages];
|
|
304
|
-
const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
|
|
305
|
-
|
|
306
|
-
const first = result[0] as unknown as Record<string, unknown>;
|
|
307
|
-
if (first && first.role === "system" && typeof first.content === "string") {
|
|
308
|
-
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
309
|
-
result[0] = { ...first, content: `${first.content}\n\n${hintText}` } as unknown as AgentMessage;
|
|
310
|
-
}
|
|
311
|
-
} else {
|
|
312
|
-
result.unshift({ role: "system", content: hintText } as unknown as AgentMessage);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return result;
|
|
316
|
-
}
|
package/extensions/client.ts
CHANGED
|
@@ -10,6 +10,8 @@ interface ProxyCompressResponse {
|
|
|
10
10
|
ccr_hashes?: string[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export class HeadroomResponseError extends Error {}
|
|
14
|
+
|
|
13
15
|
interface HeadroomClientOptions {
|
|
14
16
|
baseUrl: string;
|
|
15
17
|
timeoutMs: number;
|
|
@@ -27,12 +29,12 @@ export class HeadroomHttpClient {
|
|
|
27
29
|
async health(signal?: AbortSignal): Promise<boolean> {
|
|
28
30
|
try {
|
|
29
31
|
const response = await fetch(`${this.baseUrl}/health`, {
|
|
32
|
+
redirect: "error",
|
|
30
33
|
signal: buildSignal(this.timeoutMs, signal),
|
|
31
34
|
});
|
|
32
35
|
if (!response.ok) return false;
|
|
33
36
|
const body = await readJsonObject(response);
|
|
34
|
-
|
|
35
|
-
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
37
|
+
return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
|
|
36
38
|
} catch {
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
@@ -41,12 +43,15 @@ export class HeadroomHttpClient {
|
|
|
41
43
|
async probe(signal?: AbortSignal): Promise<boolean> {
|
|
42
44
|
try {
|
|
43
45
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
46
|
+
redirect: "error",
|
|
44
47
|
method: "POST",
|
|
45
48
|
headers: { "Content-Type": "application/json" },
|
|
46
49
|
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
|
|
47
50
|
signal: buildSignal(5_000, signal),
|
|
48
51
|
});
|
|
49
|
-
|
|
52
|
+
if (!response.ok) return false;
|
|
53
|
+
validateCompressionResponse(await response.json());
|
|
54
|
+
return true;
|
|
50
55
|
} catch {
|
|
51
56
|
return false;
|
|
52
57
|
}
|
|
@@ -54,6 +59,7 @@ export class HeadroomHttpClient {
|
|
|
54
59
|
|
|
55
60
|
async stats(signal?: AbortSignal): Promise<unknown> {
|
|
56
61
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
62
|
+
redirect: "error",
|
|
57
63
|
signal: buildSignal(this.timeoutMs, signal),
|
|
58
64
|
});
|
|
59
65
|
if (!response.ok) {
|
|
@@ -64,12 +70,18 @@ export class HeadroomHttpClient {
|
|
|
64
70
|
|
|
65
71
|
async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
|
|
66
72
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
73
|
+
redirect: "error",
|
|
67
74
|
method: "POST",
|
|
68
75
|
headers: {
|
|
69
76
|
"Content-Type": "application/json",
|
|
70
77
|
"X-Headroom-Stack": "pi-extension",
|
|
71
78
|
},
|
|
72
|
-
body: JSON.stringify({
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
messages,
|
|
81
|
+
model: model || "gpt-4o",
|
|
82
|
+
// 0.39.1's default is marker-free and inherits the backend's --lossless policy.
|
|
83
|
+
// lossy_inline would override that policy and enable lossy SmartCrusher.
|
|
84
|
+
}),
|
|
73
85
|
signal: buildSignal(this.timeoutMs, signal),
|
|
74
86
|
});
|
|
75
87
|
|
|
@@ -78,7 +90,7 @@ export class HeadroomHttpClient {
|
|
|
78
90
|
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
const payload = (await response.json())
|
|
93
|
+
const payload = validateCompressionResponse(await response.json());
|
|
82
94
|
return {
|
|
83
95
|
messages: payload.messages,
|
|
84
96
|
tokensBefore: payload.tokens_before,
|
|
@@ -92,6 +104,18 @@ export class HeadroomHttpClient {
|
|
|
92
104
|
}
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
function validateCompressionResponse(value: unknown): ProxyCompressResponse {
|
|
108
|
+
const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
|
|
109
|
+
const stringArray = (v: unknown) => Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
110
|
+
if (!isRecord(value) || !Array.isArray(value.messages) ||
|
|
111
|
+
!metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
|
|
112
|
+
(value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
|
|
113
|
+
(value.ccr_hashes != null && !stringArray(value.ccr_hashes))) {
|
|
114
|
+
throw new HeadroomResponseError("Invalid Headroom compression response");
|
|
115
|
+
}
|
|
116
|
+
return value as unknown as ProxyCompressResponse;
|
|
117
|
+
}
|
|
118
|
+
|
|
95
119
|
async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
|
|
96
120
|
try {
|
|
97
121
|
const body = (await response.json()) as unknown;
|
package/extensions/config.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { HeadroomConfig, HeadroomMode } from "./types.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
7
|
-
const DEFAULT_MIN_CONTEXT_TOKENS =
|
|
7
|
+
const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
|
|
8
8
|
const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
|
|
9
9
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
10
10
|
|
|
@@ -84,7 +84,8 @@ export function loadHeadroomConfig(
|
|
|
84
84
|
export function isLocalHeadroomUrl(rawUrl: string): boolean {
|
|
85
85
|
try {
|
|
86
86
|
const url = new URL(rawUrl);
|
|
87
|
-
return ["
|
|
87
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
|
|
88
|
+
["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
88
89
|
} catch {
|
|
89
90
|
return false;
|
|
90
91
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
convertMessage,
|
|
8
8
|
extractOpenAIText,
|
|
9
9
|
} from "./bridge.js";
|
|
10
|
-
import { HeadroomHttpClient } from "./client.js";
|
|
10
|
+
import { HeadroomHttpClient, HeadroomResponseError } from "./client.js";
|
|
11
11
|
import { isRemoteBlocked, loadHeadroomConfig, saveHeadroomSettings } from "./config.js";
|
|
12
12
|
import { startPersistentHeadroomProxy } from "./proxy-manager.js";
|
|
13
13
|
import type { AgentMessage, CompressResult, CompressionPayload, HeadroomConfig, HeadroomMode, HeadroomStats } from "./types.js";
|
|
@@ -36,6 +36,7 @@ interface HeadroomRuntimeState {
|
|
|
36
36
|
lastGuardSkipCandidateFingerprint: string | null;
|
|
37
37
|
seenCandidateContentFingerprints?: Set<string>;
|
|
38
38
|
seenCandidateContentOrder?: string[];
|
|
39
|
+
compressedCandidates?: Map<string, string>;
|
|
39
40
|
lastCompressionTime: number;
|
|
40
41
|
stats: HeadroomStats;
|
|
41
42
|
}
|
|
@@ -67,6 +68,14 @@ export default function headroomExtension(pi: ExtensionAPI) {
|
|
|
67
68
|
const runtime = createRuntime(pi);
|
|
68
69
|
|
|
69
70
|
pi.on("session_start", (_event, ctx) => {
|
|
71
|
+
// A context transform is request-local; cached substitutions must not cross sessions.
|
|
72
|
+
runtime.state.compressedCandidates = new Map();
|
|
73
|
+
runtime.state.seenCandidateContentFingerprints = new Set();
|
|
74
|
+
runtime.state.seenCandidateContentOrder = [];
|
|
75
|
+
runtime.state.lastInputFingerprint = null;
|
|
76
|
+
runtime.state.lastOutputFingerprint = null;
|
|
77
|
+
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
78
|
+
runtime.state.lastCompressionTime = 0;
|
|
70
79
|
if (isRemoteBlocked(runtime.config)) {
|
|
71
80
|
runtime.refreshStatus(ctx);
|
|
72
81
|
emitNotraceTelemetry(runtime);
|
|
@@ -135,6 +144,7 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
|
|
|
135
144
|
lastGuardSkipCandidateFingerprint: null,
|
|
136
145
|
seenCandidateContentFingerprints: new Set(),
|
|
137
146
|
seenCandidateContentOrder: [],
|
|
147
|
+
compressedCandidates: new Map(),
|
|
138
148
|
lastCompressionTime: 0,
|
|
139
149
|
stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
|
|
140
150
|
};
|
|
@@ -270,37 +280,27 @@ async function handleContextCompression(
|
|
|
270
280
|
): Promise<{ messages?: AgentMessage[] } | undefined> {
|
|
271
281
|
if (runtime.state.processing) return undefined;
|
|
272
282
|
|
|
283
|
+
let replayed: AgentMessage[] | undefined;
|
|
273
284
|
const bypassUncompressed = () => {
|
|
274
285
|
if (runtime.state.stats.last) {
|
|
275
286
|
runtime.state.stats.last = undefined;
|
|
276
287
|
runtime.refreshStatus(ctx);
|
|
277
288
|
}
|
|
278
|
-
return undefined;
|
|
289
|
+
return replayed ? { messages: replayed } : undefined;
|
|
279
290
|
};
|
|
280
291
|
|
|
281
|
-
// Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
|
|
282
|
-
const now = Date.now();
|
|
283
|
-
if (now - runtime.state.lastCompressionTime < 3000) {
|
|
284
|
-
return bypassUncompressed();
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Content-based guards to prevent infinite recursion
|
|
288
|
-
const inputFingerprint = generateFingerprint(event.messages);
|
|
289
|
-
|
|
290
|
-
// 1. If this input matches our previous output, we already compressed it. Stop.
|
|
291
|
-
if (runtime.state.lastOutputFingerprint === inputFingerprint) {
|
|
292
|
-
return undefined;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// 2. If this input matches our previous input, it didn't change. Stop.
|
|
296
|
-
if (runtime.state.lastInputFingerprint === inputFingerprint) {
|
|
297
|
-
return bypassUncompressed();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
292
|
if (shouldSkipBeforePayload(runtime, ctx)) return bypassUncompressed();
|
|
301
293
|
const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
|
|
294
|
+
replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
|
|
302
295
|
if (payload.candidateCount === 0) return bypassUncompressed();
|
|
303
296
|
|
|
297
|
+
// Pi's context transform is request-local; reuse validated text on later turns.
|
|
298
|
+
const now = Date.now();
|
|
299
|
+
if (now - runtime.state.lastCompressionTime < 3000) return bypassUncompressed();
|
|
300
|
+
const inputFingerprint = generateFingerprint(event.messages);
|
|
301
|
+
if (runtime.state.lastOutputFingerprint === inputFingerprint) return undefined;
|
|
302
|
+
if (runtime.state.lastInputFingerprint === inputFingerprint) return bypassUncompressed();
|
|
303
|
+
|
|
304
304
|
// 3. If eligible candidates haven't changed since the last skip/no-savings result,
|
|
305
305
|
// don't spend another proxy call just because surrounding conversation changed.
|
|
306
306
|
const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
|
|
@@ -347,9 +347,17 @@ async function handleContextCompression(
|
|
|
347
347
|
|
|
348
348
|
// Store fingerprints to break the feedback loop
|
|
349
349
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
350
|
-
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
351
350
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
352
351
|
recordSeenCandidateContent(runtime.state, payload, applied.messages);
|
|
352
|
+
if (replayed) {
|
|
353
|
+
for (const mapping of payload.mappings) {
|
|
354
|
+
if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
|
|
355
|
+
applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
recordCachedCandidates(runtime.state, payload, applied.messages);
|
|
360
|
+
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
353
361
|
const appliedResult = {
|
|
354
362
|
...result,
|
|
355
363
|
tokensBefore: applied.appliedTokensBefore,
|
|
@@ -371,6 +379,44 @@ async function handleContextCompression(
|
|
|
371
379
|
}
|
|
372
380
|
}
|
|
373
381
|
|
|
382
|
+
function candidateKey(message: AgentMessage, text: string): string {
|
|
383
|
+
const source = message as AgentMessage & { toolCallId?: string; toolName?: string };
|
|
384
|
+
return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function replayCachedCandidates(
|
|
388
|
+
state: HeadroomRuntimeState, messages: AgentMessage[], payload: CompressionPayload, minMessageChars: number,
|
|
389
|
+
): AgentMessage[] | undefined {
|
|
390
|
+
if (!state.compressedCandidates?.size) return undefined;
|
|
391
|
+
const mappings = payload.mappings.map((mapping) => ({
|
|
392
|
+
...mapping,
|
|
393
|
+
applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
|
|
394
|
+
? mapping.applyTo : null,
|
|
395
|
+
}));
|
|
396
|
+
const compressed = mappings.map((mapping) => {
|
|
397
|
+
const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
|
|
398
|
+
return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
|
|
399
|
+
});
|
|
400
|
+
const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
|
|
401
|
+
return applied.ok ? applied.messages : undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function recordCachedCandidates(state: HeadroomRuntimeState, payload: CompressionPayload, messages: AgentMessage[]): void {
|
|
405
|
+
state.compressedCandidates ??= new Map();
|
|
406
|
+
for (const mapping of payload.mappings) {
|
|
407
|
+
if (!mapping.applyTo) continue;
|
|
408
|
+
const applied = convertMessage(messages[mapping.sourceIndex]);
|
|
409
|
+
if (!applied || applied.role !== "tool") continue;
|
|
410
|
+
const text = extractOpenAIText(applied);
|
|
411
|
+
if (text === mapping.originalText) continue;
|
|
412
|
+
const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
|
|
413
|
+
state.compressedCandidates.set(key, text);
|
|
414
|
+
if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
|
|
415
|
+
state.compressedCandidates.delete(state.compressedCandidates.keys().next().value!);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
374
420
|
function allCandidateContentSeen(state: HeadroomRuntimeState, payload: CompressionPayload): boolean {
|
|
375
421
|
const seen = state.seenCandidateContentFingerprints;
|
|
376
422
|
if (!seen?.size) return false;
|
|
@@ -564,7 +610,8 @@ function announceGuardSkip(ctx: ExtensionContext, reason: string, result: Compre
|
|
|
564
610
|
|
|
565
611
|
function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext, error: unknown): void {
|
|
566
612
|
runtime.state.stats.lastError = getErrorMessage(error);
|
|
567
|
-
if (isAbortOrTimeoutError(error)) {
|
|
613
|
+
if (isAbortOrTimeoutError(error) || error instanceof HeadroomResponseError) {
|
|
614
|
+
// The proxy answered; invalid data is not a connectivity failure.
|
|
568
615
|
emitNotraceTelemetry(runtime);
|
|
569
616
|
runtime.refreshStatus(ctx);
|
|
570
617
|
return;
|
|
@@ -22,26 +22,32 @@ export async function startPersistentHeadroomProxy(
|
|
|
22
22
|
env: {
|
|
23
23
|
...process.env,
|
|
24
24
|
HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
|
|
25
|
+
HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
|
|
25
26
|
},
|
|
26
27
|
});
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
return await new Promise((resolve) => {
|
|
29
|
+
child.once("error", (error) => resolve({ ok: false, reason: error.message }));
|
|
30
|
+
child.once("spawn", () => {
|
|
31
|
+
child.unref();
|
|
32
|
+
resolve({ ok: true });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
29
35
|
} catch (error) {
|
|
30
36
|
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export function buildProxyArgs(endpoint: ProxyEndpoint): string[] {
|
|
35
|
-
return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "
|
|
41
|
+
return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export function parseLocalEndpoint(baseUrl: string): ProxyEndpoint | undefined {
|
|
39
45
|
try {
|
|
40
46
|
const url = new URL(baseUrl);
|
|
41
|
-
if (
|
|
47
|
+
if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return undefined;
|
|
42
48
|
if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)) return undefined;
|
|
43
49
|
const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
|
|
44
|
-
const port = url.port || "
|
|
50
|
+
const port = url.port || "80";
|
|
45
51
|
return { host, port };
|
|
46
52
|
} catch {
|
|
47
53
|
return undefined;
|
package/package.json
CHANGED