@raquezha/noheadroom 0.3.1 → 0.3.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 +6 -13
- package/dist/bridge.d.ts +1 -2
- package/dist/bridge.js +48 -33
- package/dist/client.js +27 -6
- package/dist/config.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +110 -42
- package/dist/proxy-manager.js +11 -5
- package/dist/types.d.ts +4 -0
- package/extensions/bridge.ts +45 -39
- package/extensions/client.ts +27 -5
- package/extensions/config.ts +3 -2
- package/extensions/index.ts +110 -48
- package/extensions/proxy-manager.ts +11 -5
- package/extensions/types.ts +9 -1
- 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,38 +35,59 @@ 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);
|
|
34
43
|
let appliedMessages = 0;
|
|
44
|
+
let appliedTokensBefore = 0;
|
|
45
|
+
let appliedTokensAfter = 0;
|
|
35
46
|
for (let index = 0; index < mappings.length; index++) {
|
|
36
47
|
const mapping = mappings[index];
|
|
37
48
|
const compressed = compressedMessages[index];
|
|
38
49
|
// We only validate and apply changes to explicit candidates.
|
|
39
50
|
// Headroom is allowed to mangle non-candidates (like assistant history) in its output,
|
|
40
51
|
// but we simply ignore those changes and keep the original Pi message intact.
|
|
41
|
-
if (!mapping.applyTo)
|
|
52
|
+
if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars)
|
|
42
53
|
continue;
|
|
43
54
|
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
44
55
|
if (!validation.ok)
|
|
45
56
|
return validation;
|
|
46
|
-
|
|
57
|
+
const nextText = extractOpenAIText(compressed);
|
|
47
58
|
if (nextText === mapping.originalText)
|
|
48
59
|
continue;
|
|
49
|
-
//
|
|
50
|
-
|
|
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;
|
|
51
67
|
const target = nextMessages[mapping.sourceIndex];
|
|
52
68
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
53
69
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
54
70
|
}
|
|
71
|
+
appliedTokensBefore += estimateTokens(mapping.originalText);
|
|
72
|
+
appliedTokensAfter += estimateTokens(nextText);
|
|
55
73
|
appliedMessages++;
|
|
56
74
|
}
|
|
57
75
|
if (appliedMessages === 0) {
|
|
58
76
|
return { ok: false, reason: "no-applicable-message-changed" };
|
|
59
77
|
}
|
|
60
|
-
|
|
78
|
+
const appliedTokensSaved = Math.max(0, appliedTokensBefore - appliedTokensAfter);
|
|
79
|
+
if (appliedTokensSaved === 0) {
|
|
80
|
+
return { ok: false, reason: "no-estimated-token-savings" };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
messages: nextMessages,
|
|
85
|
+
appliedMessages,
|
|
86
|
+
appliedTokensBefore,
|
|
87
|
+
appliedTokensAfter,
|
|
88
|
+
appliedTokensSaved,
|
|
89
|
+
appliedCompressionRatio: appliedTokensAfter / appliedTokensBefore,
|
|
90
|
+
};
|
|
61
91
|
}
|
|
62
92
|
export function convertMessage(message) {
|
|
63
93
|
if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role))
|
|
@@ -126,14 +156,16 @@ function convertToolCall(toolCall) {
|
|
|
126
156
|
id: toolCall.id,
|
|
127
157
|
type: "function",
|
|
128
158
|
function: {
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
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),
|
|
133
162
|
},
|
|
134
163
|
};
|
|
135
164
|
}
|
|
136
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
|
+
}
|
|
137
169
|
if (original.role !== compressed.role) {
|
|
138
170
|
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
139
171
|
}
|
|
@@ -221,24 +253,7 @@ function readStringProperty(value, key) {
|
|
|
221
253
|
function isRecord(value) {
|
|
222
254
|
return typeof value === "object" && value !== null;
|
|
223
255
|
}
|
|
224
|
-
function
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
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.]");
|
|
228
|
-
}
|
|
229
|
-
export function injectCompressionAwareness(messages) {
|
|
230
|
-
// If a system message already exists at the top, we append the hint to it.
|
|
231
|
-
// Otherwise, we inject a new system message at index 0.
|
|
232
|
-
const result = [...messages];
|
|
233
|
-
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.";
|
|
234
|
-
const first = result[0];
|
|
235
|
-
if (first && first.role === "system" && typeof first.content === "string") {
|
|
236
|
-
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
237
|
-
result[0] = { ...first, content: `${first.content}\n\n${hintText}` };
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
result.unshift({ role: "system", content: hintText });
|
|
242
|
-
}
|
|
243
|
-
return result;
|
|
256
|
+
function estimateTokens(text) {
|
|
257
|
+
// ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
|
|
258
|
+
return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
|
|
244
259
|
}
|
package/dist/client.js
CHANGED
|
@@ -8,14 +8,13 @@ export class HeadroomHttpClient {
|
|
|
8
8
|
async health(signal) {
|
|
9
9
|
try {
|
|
10
10
|
const response = await fetch(`${this.baseUrl}/health`, {
|
|
11
|
+
redirect: "error",
|
|
11
12
|
signal: buildSignal(this.timeoutMs, signal),
|
|
12
13
|
});
|
|
13
14
|
if (!response.ok)
|
|
14
15
|
return false;
|
|
15
16
|
const body = await readJsonObject(response);
|
|
16
|
-
|
|
17
|
-
return true;
|
|
18
|
-
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
17
|
+
return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
|
|
19
18
|
}
|
|
20
19
|
catch {
|
|
21
20
|
return false;
|
|
@@ -24,12 +23,16 @@ export class HeadroomHttpClient {
|
|
|
24
23
|
async probe(signal) {
|
|
25
24
|
try {
|
|
26
25
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
26
|
+
redirect: "error",
|
|
27
27
|
method: "POST",
|
|
28
28
|
headers: { "Content-Type": "application/json" },
|
|
29
29
|
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
|
|
30
30
|
signal: buildSignal(5_000, signal),
|
|
31
31
|
});
|
|
32
|
-
|
|
32
|
+
if (!response.ok)
|
|
33
|
+
return false;
|
|
34
|
+
validateCompressionResponse(await response.json());
|
|
35
|
+
return true;
|
|
33
36
|
}
|
|
34
37
|
catch {
|
|
35
38
|
return false;
|
|
@@ -37,6 +40,7 @@ export class HeadroomHttpClient {
|
|
|
37
40
|
}
|
|
38
41
|
async stats(signal) {
|
|
39
42
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
43
|
+
redirect: "error",
|
|
40
44
|
signal: buildSignal(this.timeoutMs, signal),
|
|
41
45
|
});
|
|
42
46
|
if (!response.ok) {
|
|
@@ -46,19 +50,25 @@ export class HeadroomHttpClient {
|
|
|
46
50
|
}
|
|
47
51
|
async compress(messages, model, signal) {
|
|
48
52
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
53
|
+
redirect: "error",
|
|
49
54
|
method: "POST",
|
|
50
55
|
headers: {
|
|
51
56
|
"Content-Type": "application/json",
|
|
52
57
|
"X-Headroom-Stack": "pi-extension",
|
|
53
58
|
},
|
|
54
|
-
body: JSON.stringify({
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
messages,
|
|
61
|
+
model: model || "gpt-4o",
|
|
62
|
+
// 0.39.1's default is marker-free and inherits the backend's --lossless policy.
|
|
63
|
+
// lossy_inline would override that policy and enable lossy SmartCrusher.
|
|
64
|
+
}),
|
|
55
65
|
signal: buildSignal(this.timeoutMs, signal),
|
|
56
66
|
});
|
|
57
67
|
if (!response.ok) {
|
|
58
68
|
const message = await readErrorMessage(response);
|
|
59
69
|
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
60
70
|
}
|
|
61
|
-
const payload = (await response.json());
|
|
71
|
+
const payload = validateCompressionResponse(await response.json());
|
|
62
72
|
return {
|
|
63
73
|
messages: payload.messages,
|
|
64
74
|
tokensBefore: payload.tokens_before,
|
|
@@ -71,6 +81,17 @@ export class HeadroomHttpClient {
|
|
|
71
81
|
};
|
|
72
82
|
}
|
|
73
83
|
}
|
|
84
|
+
function validateCompressionResponse(value) {
|
|
85
|
+
const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
|
|
86
|
+
const stringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
87
|
+
if (!isRecord(value) || !Array.isArray(value.messages) ||
|
|
88
|
+
!metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
|
|
89
|
+
(value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
|
|
90
|
+
(value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || value.ccr_hashes.length > 0))) {
|
|
91
|
+
throw new Error("Invalid or retrieval-dependent Headroom compression response");
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
74
95
|
async function readJsonObject(response) {
|
|
75
96
|
try {
|
|
76
97
|
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
|
@@ -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,38 +216,42 @@ function registerCompressionRenderer(pi) {
|
|
|
207
216
|
async function handleContextCompression(runtime, event, ctx) {
|
|
208
217
|
if (runtime.state.processing)
|
|
209
218
|
return undefined;
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (runtime.state.lastOutputFingerprint === inputFingerprint) {
|
|
219
|
-
return undefined;
|
|
220
|
-
}
|
|
221
|
-
// 2. If this input matches our previous input, it didn't change. Stop.
|
|
222
|
-
if (runtime.state.lastInputFingerprint === inputFingerprint) {
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
219
|
+
let replayed;
|
|
220
|
+
const bypassUncompressed = () => {
|
|
221
|
+
if (runtime.state.stats.last) {
|
|
222
|
+
runtime.state.stats.last = undefined;
|
|
223
|
+
runtime.refreshStatus(ctx);
|
|
224
|
+
}
|
|
225
|
+
return replayed ? { messages: replayed } : undefined;
|
|
226
|
+
};
|
|
225
227
|
if (shouldSkipBeforePayload(runtime, ctx))
|
|
226
|
-
return
|
|
228
|
+
return bypassUncompressed();
|
|
227
229
|
const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
|
|
230
|
+
replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
|
|
228
231
|
if (payload.candidateCount === 0)
|
|
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)
|
|
229
239
|
return undefined;
|
|
240
|
+
if (runtime.state.lastInputFingerprint === inputFingerprint)
|
|
241
|
+
return bypassUncompressed();
|
|
230
242
|
// 3. If eligible candidates haven't changed since the last skip/no-savings result,
|
|
231
243
|
// don't spend another proxy call just because surrounding conversation changed.
|
|
232
244
|
const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
|
|
233
245
|
if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
|
|
234
|
-
return
|
|
246
|
+
return bypassUncompressed();
|
|
235
247
|
}
|
|
236
248
|
if (allCandidateContentSeen(runtime.state, payload)) {
|
|
237
|
-
return
|
|
249
|
+
return bypassUncompressed();
|
|
238
250
|
}
|
|
251
|
+
ignoreSeenCandidateContent(runtime.state, payload);
|
|
239
252
|
if (runtime.state.proxyOnline !== true) {
|
|
240
253
|
void ensureProxyInBackground(runtime, ctx);
|
|
241
|
-
return
|
|
254
|
+
return bypassUncompressed();
|
|
242
255
|
}
|
|
243
256
|
runtime.state.processing = true;
|
|
244
257
|
runtime.state.lastCompressionTime = now;
|
|
@@ -251,10 +264,7 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
251
264
|
// until the actual compressible candidate material changes.
|
|
252
265
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
253
266
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
254
|
-
|
|
255
|
-
runtime.state.stats.last = undefined;
|
|
256
|
-
runtime.refreshStatus(ctx);
|
|
257
|
-
return undefined;
|
|
267
|
+
return bypassUncompressed();
|
|
258
268
|
}
|
|
259
269
|
const applied = applyCompressionResult(event.messages, payload.mappings, result.messages, {
|
|
260
270
|
minMessageChars: runtime.config.minMessageChars,
|
|
@@ -267,28 +277,79 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
267
277
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
268
278
|
emitNotraceTelemetry(runtime);
|
|
269
279
|
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
270
|
-
|
|
271
|
-
return undefined;
|
|
280
|
+
return bypassUncompressed();
|
|
272
281
|
}
|
|
273
282
|
// Store fingerprints to break the feedback loop
|
|
274
283
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
275
|
-
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
276
284
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
277
|
-
recordSeenCandidateContent(runtime.state, payload,
|
|
278
|
-
|
|
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);
|
|
295
|
+
const appliedResult = {
|
|
296
|
+
...result,
|
|
297
|
+
tokensBefore: applied.appliedTokensBefore,
|
|
298
|
+
tokensAfter: applied.appliedTokensAfter,
|
|
299
|
+
tokensSaved: applied.appliedTokensSaved,
|
|
300
|
+
compressionRatio: applied.appliedCompressionRatio,
|
|
301
|
+
};
|
|
302
|
+
recordAppliedCompression(runtime.state.stats, appliedResult, applied.appliedMessages);
|
|
279
303
|
emitNotraceTelemetry(runtime);
|
|
280
|
-
announceAppliedCompression(runtime.pi, ctx,
|
|
304
|
+
announceAppliedCompression(runtime.pi, ctx, appliedResult, applied.appliedMessages, runtime.config.mode);
|
|
281
305
|
runtime.refreshStatus(ctx);
|
|
282
306
|
return { messages: applied.messages };
|
|
283
307
|
}
|
|
284
308
|
catch (error) {
|
|
285
309
|
recordCompressionError(runtime, ctx, error);
|
|
286
|
-
return
|
|
310
|
+
return bypassUncompressed();
|
|
287
311
|
}
|
|
288
312
|
finally {
|
|
289
313
|
runtime.state.processing = false;
|
|
290
314
|
}
|
|
291
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
|
+
}
|
|
292
353
|
function allCandidateContentSeen(state, payload) {
|
|
293
354
|
const seen = state.seenCandidateContentFingerprints;
|
|
294
355
|
if (!seen?.size)
|
|
@@ -296,16 +357,25 @@ function allCandidateContentSeen(state, payload) {
|
|
|
296
357
|
const hashes = payload.mappings.filter((mapping) => mapping.applyTo).map((mapping) => stableHash(mapping.originalText));
|
|
297
358
|
return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
|
|
298
359
|
}
|
|
299
|
-
function
|
|
360
|
+
function ignoreSeenCandidateContent(state, payload) {
|
|
361
|
+
const seen = state.seenCandidateContentFingerprints;
|
|
362
|
+
if (!seen?.size)
|
|
363
|
+
return;
|
|
364
|
+
for (const mapping of payload.mappings) {
|
|
365
|
+
if (mapping.applyTo && seen.has(stableHash(mapping.originalText)))
|
|
366
|
+
mapping.applyTo = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function recordSeenCandidateContent(state, payload, appliedMessages) {
|
|
300
370
|
state.seenCandidateContentFingerprints ??= new Set();
|
|
301
371
|
state.seenCandidateContentOrder ??= [];
|
|
302
|
-
payload.mappings.forEach((mapping
|
|
372
|
+
payload.mappings.forEach((mapping) => {
|
|
303
373
|
if (!mapping.applyTo)
|
|
304
374
|
return;
|
|
305
375
|
addSeenCandidateHash(state, stableHash(mapping.originalText));
|
|
306
|
-
const
|
|
307
|
-
if (
|
|
308
|
-
addSeenCandidateHash(state, stableHash(extractOpenAIText(
|
|
376
|
+
const applied = convertMessage(appliedMessages[mapping.sourceIndex]);
|
|
377
|
+
if (applied)
|
|
378
|
+
addSeenCandidateHash(state, stableHash(extractOpenAIText(applied)));
|
|
309
379
|
});
|
|
310
380
|
}
|
|
311
381
|
function addSeenCandidateHash(state, hash) {
|
|
@@ -391,7 +461,7 @@ function recordAppliedCompression(stats, result, appliedMessages) {
|
|
|
391
461
|
function summarizeTelemetry(state) {
|
|
392
462
|
if (state.stats.last) {
|
|
393
463
|
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
394
|
-
return `
|
|
464
|
+
return `last compression estimated about ${state.stats.last.tokensSaved.toLocaleString()} tokens saved (-${pct}%) across ${state.stats.last.appliedMessages} tool results; local session estimate about ${state.stats.tokensSaved.toLocaleString()} tokens saved`;
|
|
395
465
|
}
|
|
396
466
|
if (!state.enabled)
|
|
397
467
|
return "headroom loaded but disabled for this session";
|
|
@@ -443,7 +513,7 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages, mode = "no
|
|
|
443
513
|
if (mode === "quiet" || mode === "silent")
|
|
444
514
|
return;
|
|
445
515
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
446
|
-
const summary = `compressed ${
|
|
516
|
+
const summary = `compressed ${appliedMessages} tool results; estimated ~${result.tokensSaved.toLocaleString()} tokens saved (-${pct}%)`;
|
|
447
517
|
const line = `noheadroom: ${summary}`;
|
|
448
518
|
ctx.ui.notify(line, "info");
|
|
449
519
|
// Note: we no longer use pi.appendEntry or pi.sendMessage here because
|
|
@@ -561,10 +631,7 @@ function renderFooterStatus(ctx, config, state) {
|
|
|
561
631
|
return paint("dim", "○ Headroom not running");
|
|
562
632
|
if (state.proxyOnline === null && !state.stats.last)
|
|
563
633
|
return paint("dim", "○ Headroom idle");
|
|
564
|
-
|
|
565
|
-
return paint("success", "✓") + paint("dim", " Headroom");
|
|
566
|
-
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
567
|
-
return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`));
|
|
634
|
+
return paint("success", "✓") + paint("dim", " Headroom active");
|
|
568
635
|
}
|
|
569
636
|
async function showProxyStats(ctx, client, config) {
|
|
570
637
|
if (isRemoteBlocked(config)) {
|
|
@@ -596,7 +663,8 @@ function renderStatus(config, state) {
|
|
|
596
663
|
` Attempts: ${stats.attempts}`,
|
|
597
664
|
` Applied: ${stats.applied}`,
|
|
598
665
|
` Guard skips: ${stats.guardSkips}`,
|
|
599
|
-
`
|
|
666
|
+
` Estimated tokens saved: ~${stats.tokensSaved.toLocaleString()}`,
|
|
667
|
+
` Note: local estimate; proxy /stats does not track Pi extension /v1/compress calls.`,
|
|
600
668
|
];
|
|
601
669
|
if (stats.last) {
|
|
602
670
|
const pct = Math.round((1 - stats.last.compressionRatio) * 100);
|
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/dist/types.d.ts
CHANGED
|
@@ -96,6 +96,10 @@ export type ApplyCompressionResult = {
|
|
|
96
96
|
ok: true;
|
|
97
97
|
messages: AgentMessage[];
|
|
98
98
|
appliedMessages: number;
|
|
99
|
+
appliedTokensBefore: number;
|
|
100
|
+
appliedTokensAfter: number;
|
|
101
|
+
appliedTokensSaved: number;
|
|
102
|
+
appliedCompressionRatio: number;
|
|
99
103
|
} | {
|
|
100
104
|
ok: false;
|
|
101
105
|
reason: string;
|
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,14 +62,16 @@ 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
|
|
|
62
71
|
const nextMessages = structuredClone(originalMessages) as AgentMessage[];
|
|
63
72
|
let appliedMessages = 0;
|
|
73
|
+
let appliedTokensBefore = 0;
|
|
74
|
+
let appliedTokensAfter = 0;
|
|
64
75
|
|
|
65
76
|
for (let index = 0; index < mappings.length; index++) {
|
|
66
77
|
const mapping = mappings[index];
|
|
@@ -69,29 +80,44 @@ export function applyCompressionResult(
|
|
|
69
80
|
// We only validate and apply changes to explicit candidates.
|
|
70
81
|
// Headroom is allowed to mangle non-candidates (like assistant history) in its output,
|
|
71
82
|
// but we simply ignore those changes and keep the original Pi message intact.
|
|
72
|
-
if (!mapping.applyTo) continue;
|
|
83
|
+
if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars) continue;
|
|
73
84
|
|
|
74
85
|
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
75
86
|
if (!validation.ok) return validation;
|
|
76
87
|
|
|
77
|
-
|
|
88
|
+
const nextText = extractOpenAIText(compressed);
|
|
78
89
|
if (nextText === mapping.originalText) continue;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
94
|
|
|
83
95
|
const target = nextMessages[mapping.sourceIndex] as AnyMessage;
|
|
84
96
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
85
97
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
86
98
|
}
|
|
99
|
+
appliedTokensBefore += estimateTokens(mapping.originalText);
|
|
100
|
+
appliedTokensAfter += estimateTokens(nextText);
|
|
87
101
|
appliedMessages++;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
if (appliedMessages === 0) {
|
|
91
105
|
return { ok: false, reason: "no-applicable-message-changed" };
|
|
92
106
|
}
|
|
107
|
+
const appliedTokensSaved = Math.max(0, appliedTokensBefore - appliedTokensAfter);
|
|
108
|
+
if (appliedTokensSaved === 0) {
|
|
109
|
+
return { ok: false, reason: "no-estimated-token-savings" };
|
|
110
|
+
}
|
|
93
111
|
|
|
94
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
messages: nextMessages,
|
|
115
|
+
appliedMessages,
|
|
116
|
+
appliedTokensBefore,
|
|
117
|
+
appliedTokensAfter,
|
|
118
|
+
appliedTokensSaved,
|
|
119
|
+
appliedCompressionRatio: appliedTokensAfter / appliedTokensBefore,
|
|
120
|
+
};
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
export function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
|
|
@@ -159,10 +185,9 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
|
|
|
159
185
|
id: toolCall.id,
|
|
160
186
|
type: "function",
|
|
161
187
|
function: {
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
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),
|
|
166
191
|
},
|
|
167
192
|
};
|
|
168
193
|
}
|
|
@@ -171,6 +196,9 @@ function validateAlignedMessage(
|
|
|
171
196
|
original: OpenAIMessage,
|
|
172
197
|
compressed: OpenAIMessage,
|
|
173
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
|
+
}
|
|
174
202
|
if (original.role !== compressed.role) {
|
|
175
203
|
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
176
204
|
}
|
|
@@ -267,29 +295,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
267
295
|
return typeof value === "object" && value !== null;
|
|
268
296
|
}
|
|
269
297
|
|
|
270
|
-
function
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
return text.replace(
|
|
274
|
-
/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi,
|
|
275
|
-
"[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]"
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[] {
|
|
280
|
-
// If a system message already exists at the top, we append the hint to it.
|
|
281
|
-
// Otherwise, we inject a new system message at index 0.
|
|
282
|
-
const result = [...messages];
|
|
283
|
-
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.";
|
|
284
|
-
|
|
285
|
-
const first = result[0] as unknown as Record<string, unknown>;
|
|
286
|
-
if (first && first.role === "system" && typeof first.content === "string") {
|
|
287
|
-
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
288
|
-
result[0] = { ...first, content: `${first.content}\n\n${hintText}` } as unknown as AgentMessage;
|
|
289
|
-
}
|
|
290
|
-
} else {
|
|
291
|
-
result.unshift({ role: "system", content: hintText } as unknown as AgentMessage);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return result;
|
|
298
|
+
function estimateTokens(text: string): number {
|
|
299
|
+
// ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
|
|
300
|
+
return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
|
|
295
301
|
}
|
package/extensions/client.ts
CHANGED
|
@@ -27,12 +27,12 @@ export class HeadroomHttpClient {
|
|
|
27
27
|
async health(signal?: AbortSignal): Promise<boolean> {
|
|
28
28
|
try {
|
|
29
29
|
const response = await fetch(`${this.baseUrl}/health`, {
|
|
30
|
+
redirect: "error",
|
|
30
31
|
signal: buildSignal(this.timeoutMs, signal),
|
|
31
32
|
});
|
|
32
33
|
if (!response.ok) return false;
|
|
33
34
|
const body = await readJsonObject(response);
|
|
34
|
-
|
|
35
|
-
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
35
|
+
return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
|
|
36
36
|
} catch {
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
@@ -41,12 +41,15 @@ export class HeadroomHttpClient {
|
|
|
41
41
|
async probe(signal?: AbortSignal): Promise<boolean> {
|
|
42
42
|
try {
|
|
43
43
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
44
|
+
redirect: "error",
|
|
44
45
|
method: "POST",
|
|
45
46
|
headers: { "Content-Type": "application/json" },
|
|
46
47
|
body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
|
|
47
48
|
signal: buildSignal(5_000, signal),
|
|
48
49
|
});
|
|
49
|
-
|
|
50
|
+
if (!response.ok) return false;
|
|
51
|
+
validateCompressionResponse(await response.json());
|
|
52
|
+
return true;
|
|
50
53
|
} catch {
|
|
51
54
|
return false;
|
|
52
55
|
}
|
|
@@ -54,6 +57,7 @@ export class HeadroomHttpClient {
|
|
|
54
57
|
|
|
55
58
|
async stats(signal?: AbortSignal): Promise<unknown> {
|
|
56
59
|
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
60
|
+
redirect: "error",
|
|
57
61
|
signal: buildSignal(this.timeoutMs, signal),
|
|
58
62
|
});
|
|
59
63
|
if (!response.ok) {
|
|
@@ -64,12 +68,18 @@ export class HeadroomHttpClient {
|
|
|
64
68
|
|
|
65
69
|
async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
|
|
66
70
|
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
71
|
+
redirect: "error",
|
|
67
72
|
method: "POST",
|
|
68
73
|
headers: {
|
|
69
74
|
"Content-Type": "application/json",
|
|
70
75
|
"X-Headroom-Stack": "pi-extension",
|
|
71
76
|
},
|
|
72
|
-
body: JSON.stringify({
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
messages,
|
|
79
|
+
model: model || "gpt-4o",
|
|
80
|
+
// 0.39.1's default is marker-free and inherits the backend's --lossless policy.
|
|
81
|
+
// lossy_inline would override that policy and enable lossy SmartCrusher.
|
|
82
|
+
}),
|
|
73
83
|
signal: buildSignal(this.timeoutMs, signal),
|
|
74
84
|
});
|
|
75
85
|
|
|
@@ -78,7 +88,7 @@ export class HeadroomHttpClient {
|
|
|
78
88
|
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
const payload = (await response.json())
|
|
91
|
+
const payload = validateCompressionResponse(await response.json());
|
|
82
92
|
return {
|
|
83
93
|
messages: payload.messages,
|
|
84
94
|
tokensBefore: payload.tokens_before,
|
|
@@ -92,6 +102,18 @@ export class HeadroomHttpClient {
|
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
function validateCompressionResponse(value: unknown): ProxyCompressResponse {
|
|
106
|
+
const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
|
|
107
|
+
const stringArray = (v: unknown) => Array.isArray(v) && v.every((item) => typeof item === "string");
|
|
108
|
+
if (!isRecord(value) || !Array.isArray(value.messages) ||
|
|
109
|
+
!metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
|
|
110
|
+
(value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
|
|
111
|
+
(value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || (value.ccr_hashes as string[]).length > 0))) {
|
|
112
|
+
throw new Error("Invalid or retrieval-dependent Headroom compression response");
|
|
113
|
+
}
|
|
114
|
+
return value as unknown as ProxyCompressResponse;
|
|
115
|
+
}
|
|
116
|
+
|
|
95
117
|
async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
|
|
96
118
|
try {
|
|
97
119
|
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
|
@@ -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,41 +280,40 @@ async function handleContextCompression(
|
|
|
270
280
|
): Promise<{ messages?: AgentMessage[] } | undefined> {
|
|
271
281
|
if (runtime.state.processing) return undefined;
|
|
272
282
|
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// 1. If this input matches our previous output, we already compressed it. Stop.
|
|
283
|
-
if (runtime.state.lastOutputFingerprint === inputFingerprint) {
|
|
284
|
-
return undefined;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// 2. If this input matches our previous input, it didn't change. Stop.
|
|
288
|
-
if (runtime.state.lastInputFingerprint === inputFingerprint) {
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
283
|
+
let replayed: AgentMessage[] | undefined;
|
|
284
|
+
const bypassUncompressed = () => {
|
|
285
|
+
if (runtime.state.stats.last) {
|
|
286
|
+
runtime.state.stats.last = undefined;
|
|
287
|
+
runtime.refreshStatus(ctx);
|
|
288
|
+
}
|
|
289
|
+
return replayed ? { messages: replayed } : undefined;
|
|
290
|
+
};
|
|
291
291
|
|
|
292
|
-
if (shouldSkipBeforePayload(runtime, ctx)) return
|
|
292
|
+
if (shouldSkipBeforePayload(runtime, ctx)) return bypassUncompressed();
|
|
293
293
|
const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
|
|
294
|
-
|
|
294
|
+
replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
|
|
295
|
+
if (payload.candidateCount === 0) return bypassUncompressed();
|
|
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();
|
|
295
303
|
|
|
296
304
|
// 3. If eligible candidates haven't changed since the last skip/no-savings result,
|
|
297
305
|
// don't spend another proxy call just because surrounding conversation changed.
|
|
298
306
|
const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
|
|
299
307
|
if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
|
|
300
|
-
return
|
|
308
|
+
return bypassUncompressed();
|
|
301
309
|
}
|
|
302
310
|
if (allCandidateContentSeen(runtime.state, payload)) {
|
|
303
|
-
return
|
|
311
|
+
return bypassUncompressed();
|
|
304
312
|
}
|
|
313
|
+
ignoreSeenCandidateContent(runtime.state, payload);
|
|
305
314
|
if (runtime.state.proxyOnline !== true) {
|
|
306
315
|
void ensureProxyInBackground(runtime, ctx);
|
|
307
|
-
return
|
|
316
|
+
return bypassUncompressed();
|
|
308
317
|
}
|
|
309
318
|
|
|
310
319
|
runtime.state.processing = true;
|
|
@@ -318,10 +327,7 @@ async function handleContextCompression(
|
|
|
318
327
|
// until the actual compressible candidate material changes.
|
|
319
328
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
320
329
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
321
|
-
|
|
322
|
-
runtime.state.stats.last = undefined;
|
|
323
|
-
runtime.refreshStatus(ctx);
|
|
324
|
-
return undefined;
|
|
330
|
+
return bypassUncompressed();
|
|
325
331
|
}
|
|
326
332
|
|
|
327
333
|
const applied = applyCompressionResult(event.messages, payload.mappings, result.messages, {
|
|
@@ -336,29 +342,81 @@ async function handleContextCompression(
|
|
|
336
342
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
337
343
|
emitNotraceTelemetry(runtime);
|
|
338
344
|
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
339
|
-
|
|
340
|
-
return undefined;
|
|
345
|
+
return bypassUncompressed();
|
|
341
346
|
}
|
|
342
347
|
|
|
343
348
|
// Store fingerprints to break the feedback loop
|
|
344
349
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
345
|
-
runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
|
|
346
350
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
347
|
-
recordSeenCandidateContent(runtime.state, payload,
|
|
348
|
-
|
|
349
|
-
|
|
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);
|
|
361
|
+
const appliedResult = {
|
|
362
|
+
...result,
|
|
363
|
+
tokensBefore: applied.appliedTokensBefore,
|
|
364
|
+
tokensAfter: applied.appliedTokensAfter,
|
|
365
|
+
tokensSaved: applied.appliedTokensSaved,
|
|
366
|
+
compressionRatio: applied.appliedCompressionRatio,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
recordAppliedCompression(runtime.state.stats, appliedResult, applied.appliedMessages);
|
|
350
370
|
emitNotraceTelemetry(runtime);
|
|
351
|
-
announceAppliedCompression(runtime.pi, ctx,
|
|
371
|
+
announceAppliedCompression(runtime.pi, ctx, appliedResult, applied.appliedMessages, runtime.config.mode);
|
|
352
372
|
runtime.refreshStatus(ctx);
|
|
353
373
|
return { messages: applied.messages };
|
|
354
374
|
} catch (error) {
|
|
355
375
|
recordCompressionError(runtime, ctx, error);
|
|
356
|
-
return
|
|
376
|
+
return bypassUncompressed();
|
|
357
377
|
} finally {
|
|
358
378
|
runtime.state.processing = false;
|
|
359
379
|
}
|
|
360
380
|
}
|
|
361
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
|
+
|
|
362
420
|
function allCandidateContentSeen(state: HeadroomRuntimeState, payload: CompressionPayload): boolean {
|
|
363
421
|
const seen = state.seenCandidateContentFingerprints;
|
|
364
422
|
if (!seen?.size) return false;
|
|
@@ -366,18 +424,26 @@ function allCandidateContentSeen(state: HeadroomRuntimeState, payload: Compressi
|
|
|
366
424
|
return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
|
|
367
425
|
}
|
|
368
426
|
|
|
427
|
+
function ignoreSeenCandidateContent(state: HeadroomRuntimeState, payload: CompressionPayload): void {
|
|
428
|
+
const seen = state.seenCandidateContentFingerprints;
|
|
429
|
+
if (!seen?.size) return;
|
|
430
|
+
for (const mapping of payload.mappings) {
|
|
431
|
+
if (mapping.applyTo && seen.has(stableHash(mapping.originalText))) mapping.applyTo = null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
369
435
|
function recordSeenCandidateContent(
|
|
370
436
|
state: HeadroomRuntimeState,
|
|
371
437
|
payload: CompressionPayload,
|
|
372
|
-
|
|
438
|
+
appliedMessages: AgentMessage[],
|
|
373
439
|
): void {
|
|
374
440
|
state.seenCandidateContentFingerprints ??= new Set();
|
|
375
441
|
state.seenCandidateContentOrder ??= [];
|
|
376
|
-
payload.mappings.forEach((mapping
|
|
442
|
+
payload.mappings.forEach((mapping) => {
|
|
377
443
|
if (!mapping.applyTo) return;
|
|
378
444
|
addSeenCandidateHash(state, stableHash(mapping.originalText));
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
445
|
+
const applied = convertMessage(appliedMessages[mapping.sourceIndex]);
|
|
446
|
+
if (applied) addSeenCandidateHash(state, stableHash(extractOpenAIText(applied)));
|
|
381
447
|
});
|
|
382
448
|
}
|
|
383
449
|
|
|
@@ -472,7 +538,7 @@ function recordAppliedCompression(stats: HeadroomStats, result: CompressResult,
|
|
|
472
538
|
function summarizeTelemetry(state: HeadroomRuntimeState): string | null {
|
|
473
539
|
if (state.stats.last) {
|
|
474
540
|
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
475
|
-
return `
|
|
541
|
+
return `last compression estimated about ${state.stats.last.tokensSaved.toLocaleString()} tokens saved (-${pct}%) across ${state.stats.last.appliedMessages} tool results; local session estimate about ${state.stats.tokensSaved.toLocaleString()} tokens saved`;
|
|
476
542
|
}
|
|
477
543
|
if (!state.enabled) return "headroom loaded but disabled for this session";
|
|
478
544
|
if (state.stats.lastSkipReason) return `compression not applied; last guard skip: ${state.stats.lastSkipReason}`;
|
|
@@ -524,7 +590,7 @@ function announceAppliedCompression(
|
|
|
524
590
|
): void {
|
|
525
591
|
if (mode === "quiet" || mode === "silent") return;
|
|
526
592
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
527
|
-
const summary = `compressed ${
|
|
593
|
+
const summary = `compressed ${appliedMessages} tool results; estimated ~${result.tokensSaved.toLocaleString()} tokens saved (-${pct}%)`;
|
|
528
594
|
const line = `noheadroom: ${summary}`;
|
|
529
595
|
ctx.ui.notify(line, "info");
|
|
530
596
|
// Note: we no longer use pi.appendEntry or pi.sendMessage here because
|
|
@@ -661,12 +727,7 @@ function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state
|
|
|
661
727
|
if (state.proxyStarting) return paint("dim", "⏳ Headroom starting");
|
|
662
728
|
if (state.proxyOnline === false) return paint("dim", "○ Headroom not running");
|
|
663
729
|
if (state.proxyOnline === null && !state.stats.last) return paint("dim", "○ Headroom idle");
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
667
|
-
return (
|
|
668
|
-
paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`)
|
|
669
|
-
);
|
|
730
|
+
return paint("success", "✓") + paint("dim", " Headroom active");
|
|
670
731
|
}
|
|
671
732
|
|
|
672
733
|
async function showProxyStats(
|
|
@@ -706,7 +767,8 @@ function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): stri
|
|
|
706
767
|
` Attempts: ${stats.attempts}`,
|
|
707
768
|
` Applied: ${stats.applied}`,
|
|
708
769
|
` Guard skips: ${stats.guardSkips}`,
|
|
709
|
-
`
|
|
770
|
+
` Estimated tokens saved: ~${stats.tokensSaved.toLocaleString()}`,
|
|
771
|
+
` Note: local estimate; proxy /stats does not track Pi extension /v1/compress calls.`,
|
|
710
772
|
];
|
|
711
773
|
if (stats.last) {
|
|
712
774
|
const pct = Math.round((1 - stats.last.compressionRatio) * 100);
|
|
@@ -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/extensions/types.ts
CHANGED
|
@@ -108,5 +108,13 @@ export interface ApplyCompressionOptions {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export type ApplyCompressionResult =
|
|
111
|
-
| {
|
|
111
|
+
| {
|
|
112
|
+
ok: true;
|
|
113
|
+
messages: AgentMessage[];
|
|
114
|
+
appliedMessages: number;
|
|
115
|
+
appliedTokensBefore: number;
|
|
116
|
+
appliedTokensAfter: number;
|
|
117
|
+
appliedTokensSaved: number;
|
|
118
|
+
appliedCompressionRatio: number;
|
|
119
|
+
}
|
|
112
120
|
| { ok: false; reason: string };
|
package/package.json
CHANGED