@ryan_nookpi/pi-extension-headroom 0.1.0
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 +59 -0
- package/bridge.ts +238 -0
- package/client.ts +112 -0
- package/config.ts +55 -0
- package/index.ts +416 -0
- package/package.json +48 -0
- package/proxy-manager.ts +58 -0
- package/types.ts +109 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @ryan_nookpi/pi-extension-headroom
|
|
2
|
+
|
|
3
|
+
This extension reclaims context window in pi by compressing large tool results through a [Headroom](https://github.com/headroom-ai/headroom) proxy before each LLM call.
|
|
4
|
+
|
|
5
|
+
Headroom runs locally, compresses only oversized `toolResult` payloads, and leaves your prompts, assistant turns, and tool-call metadata untouched. Compression is applied only when the proxy is online and the change passes strict alignment guards, so it never silently rewrites your conversation.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@ryan_nookpi/pi-extension-headroom
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
You also need the Headroom proxy available on your machine:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install "headroom-ai[proxy]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
By default the extension auto-starts a local token-mode proxy (`headroom proxy --mode token --no-cache`) on `http://127.0.0.1:8788` and leaves it running after pi exits.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
- Listens on the `context` event fired before each LLM call.
|
|
24
|
+
- Skips entirely until context usage reaches the configured token threshold.
|
|
25
|
+
- Sends an OpenAI-shaped copy of the conversation to the proxy's `/v1/compress` endpoint.
|
|
26
|
+
- Applies the result only to large `toolResult` messages, preserving pi metadata (`toolName`, `details`, tool-call ids, images).
|
|
27
|
+
- Rejects any response that changes message count, roles, tool-call ids, or non-candidate content.
|
|
28
|
+
|
|
29
|
+
## Privacy
|
|
30
|
+
|
|
31
|
+
Compression sends conversation context to the proxy, so remote URLs are blocked by default. Only `localhost`/`127.0.0.1`/`::1` are allowed unless you explicitly set `PI_HEADROOM_ALLOW_REMOTE=1` for a proxy you trust.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
- `/headroom` — show current status and session stats.
|
|
36
|
+
- `/headroom on` — enable compression and ensure the proxy is running.
|
|
37
|
+
- `/headroom off` — disable compression for this session (the proxy keeps running).
|
|
38
|
+
- `/headroom health` — check / start the proxy and report whether it is online.
|
|
39
|
+
- `/headroom stats` — print the proxy's own `/stats` output.
|
|
40
|
+
- `/headroom-health` — shortcut for `/headroom health`.
|
|
41
|
+
|
|
42
|
+
The footer shows a compact status (`✓ Headroom -42% (12,345 saved)`) once compression is applied.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
All settings are environment variables read at startup:
|
|
47
|
+
|
|
48
|
+
| Variable | Default | Description |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `PI_HEADROOM_ENABLED` | `true` | Enable compression on start. |
|
|
51
|
+
| `PI_HEADROOM_URL` | `http://127.0.0.1:8788` | Proxy base URL (`HEADROOM_URL` / `HEADROOM_BASE_URL` also accepted). |
|
|
52
|
+
| `PI_HEADROOM_ALLOW_REMOTE` | `false` | Allow non-local proxy URLs. |
|
|
53
|
+
| `PI_HEADROOM_AUTO_START` | `true` | Auto-start a local persistent proxy when offline. |
|
|
54
|
+
| `PI_HEADROOM_COMMAND` | `headroom` | Command used to launch the proxy. |
|
|
55
|
+
| `PI_HEADROOM_MIN_CONTEXT_TOKENS` | `20000` | Skip compression below this context token count. |
|
|
56
|
+
| `PI_HEADROOM_MIN_MESSAGE_CHARS` | `2000` | Only compress tool results at or above this size. |
|
|
57
|
+
| `PI_HEADROOM_TIMEOUT_MS` | `15000` | HTTP timeout for proxy requests. |
|
|
58
|
+
|
|
59
|
+
Boolean values accept `1/0`, `true/false`, `yes/no`, `on/off`.
|
package/bridge.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ImageContent, TextContent, ToolCall } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
AgentMessage,
|
|
4
|
+
ApplyCompressionOptions,
|
|
5
|
+
ApplyCompressionResult,
|
|
6
|
+
CompressionMapping,
|
|
7
|
+
CompressionPayload,
|
|
8
|
+
OpenAIAssistantMessage,
|
|
9
|
+
OpenAIMessage,
|
|
10
|
+
OpenAIToolCall,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
type AnyMessage = AgentMessage & { role?: string; content?: unknown; timestamp?: number };
|
|
14
|
+
|
|
15
|
+
interface MessageWithContent {
|
|
16
|
+
content: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
20
|
+
|
|
21
|
+
export function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload {
|
|
22
|
+
const mappings: CompressionMapping[] = [];
|
|
23
|
+
let candidateCount = 0;
|
|
24
|
+
|
|
25
|
+
for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
|
|
26
|
+
const source = messages[sourceIndex] as AnyMessage;
|
|
27
|
+
const converted = convertMessage(source);
|
|
28
|
+
if (!converted) continue;
|
|
29
|
+
|
|
30
|
+
const originalText = extractOpenAIText(converted);
|
|
31
|
+
const applyTo = source.role === "toolResult" && originalText.length >= minMessageChars ? "toolResult" : null;
|
|
32
|
+
if (applyTo) candidateCount++;
|
|
33
|
+
mappings.push({ sourceIndex, message: converted, applyTo, originalText });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
messages: mappings.map((mapping) => mapping.message),
|
|
38
|
+
mappings,
|
|
39
|
+
candidateCount,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function applyCompressionResult(
|
|
44
|
+
originalMessages: AgentMessage[],
|
|
45
|
+
mappings: CompressionMapping[],
|
|
46
|
+
compressedMessages: OpenAIMessage[],
|
|
47
|
+
_options: ApplyCompressionOptions,
|
|
48
|
+
): ApplyCompressionResult {
|
|
49
|
+
if (compressedMessages.length !== mappings.length) {
|
|
50
|
+
return { ok: false, reason: "message-count-changed" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nextMessages = structuredClone(originalMessages) as AgentMessage[];
|
|
54
|
+
let appliedMessages = 0;
|
|
55
|
+
|
|
56
|
+
for (let index = 0; index < mappings.length; index++) {
|
|
57
|
+
const mapping = mappings[index];
|
|
58
|
+
const compressed = compressedMessages[index];
|
|
59
|
+
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
60
|
+
if (!validation.ok) return validation;
|
|
61
|
+
|
|
62
|
+
const nextText = extractOpenAIText(compressed);
|
|
63
|
+
if (nextText === mapping.originalText) continue;
|
|
64
|
+
|
|
65
|
+
if (mapping.applyTo !== "toolResult") {
|
|
66
|
+
return { ok: false, reason: `non-candidate-changed:${mapping.message.role}` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const target = nextMessages[mapping.sourceIndex] as AnyMessage;
|
|
70
|
+
if (target.role !== "toolResult") {
|
|
71
|
+
return { ok: false, reason: "source-role-mismatch" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!replaceTextContent(target, nextText)) {
|
|
75
|
+
return { ok: false, reason: "target-content-unreplaceable" };
|
|
76
|
+
}
|
|
77
|
+
appliedMessages++;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (appliedMessages === 0) {
|
|
81
|
+
return { ok: false, reason: "no-applicable-message-changed" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, messages: nextMessages, appliedMessages };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
|
|
88
|
+
if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role)) return undefined;
|
|
89
|
+
switch (message.role) {
|
|
90
|
+
case "user":
|
|
91
|
+
return convertUserMessage(message);
|
|
92
|
+
case "assistant":
|
|
93
|
+
return convertAssistantMessage(message);
|
|
94
|
+
case "toolResult":
|
|
95
|
+
return convertToolResultMessage(message);
|
|
96
|
+
default:
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function convertUserMessage(message: AnyMessage): OpenAIMessage | undefined {
|
|
102
|
+
if (!hasContent(message)) return undefined;
|
|
103
|
+
if (typeof message.content === "string") {
|
|
104
|
+
return { role: "user", content: message.content };
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(message.content)) return undefined;
|
|
107
|
+
|
|
108
|
+
const text = message.content
|
|
109
|
+
.filter((part): part is TextContent => isTextContent(part))
|
|
110
|
+
.map((part) => part.text)
|
|
111
|
+
.join("\n");
|
|
112
|
+
if (text) return { role: "user", content: text };
|
|
113
|
+
const hasImages = message.content.some((part) => isImageContent(part));
|
|
114
|
+
return hasImages ? { role: "user", content: "[image omitted from Headroom compression payload]" } : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function convertAssistantMessage(message: AnyMessage): OpenAIAssistantMessage | undefined {
|
|
118
|
+
if (!hasContent(message) || !Array.isArray(message.content)) return undefined;
|
|
119
|
+
const text = message.content
|
|
120
|
+
.filter((part): part is TextContent => isTextContent(part))
|
|
121
|
+
.map((part) => part.text)
|
|
122
|
+
.join("");
|
|
123
|
+
const toolCalls = message.content.filter((part): part is ToolCall => isToolCall(part));
|
|
124
|
+
if (!text && toolCalls.length === 0) return undefined;
|
|
125
|
+
|
|
126
|
+
const converted: OpenAIAssistantMessage = {
|
|
127
|
+
role: "assistant",
|
|
128
|
+
content: text || null,
|
|
129
|
+
};
|
|
130
|
+
if (toolCalls.length > 0) {
|
|
131
|
+
converted.tool_calls = toolCalls.map(convertToolCall);
|
|
132
|
+
}
|
|
133
|
+
return converted;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function convertToolResultMessage(message: AnyMessage): OpenAIMessage | undefined {
|
|
137
|
+
if (!hasContent(message)) return undefined;
|
|
138
|
+
const toolCallId = readStringProperty(message, "toolCallId");
|
|
139
|
+
if (!toolCallId) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
role: "tool",
|
|
142
|
+
content: extractTextFromContent(message.content),
|
|
143
|
+
tool_call_id: toolCallId,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
|
|
148
|
+
return {
|
|
149
|
+
id: toolCall.id,
|
|
150
|
+
type: "function",
|
|
151
|
+
function: {
|
|
152
|
+
name: toolCall.name,
|
|
153
|
+
arguments: JSON.stringify(toolCall.arguments),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateAlignedMessage(
|
|
159
|
+
original: OpenAIMessage,
|
|
160
|
+
compressed: OpenAIMessage,
|
|
161
|
+
): { ok: true } | { ok: false; reason: string } {
|
|
162
|
+
if (original.role !== compressed.role) {
|
|
163
|
+
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
164
|
+
}
|
|
165
|
+
if (original.role === "tool" && compressed.role === "tool") {
|
|
166
|
+
if (original.tool_call_id !== compressed.tool_call_id) return { ok: false, reason: "tool-call-id-changed" };
|
|
167
|
+
}
|
|
168
|
+
if (original.role === "assistant" && compressed.role === "assistant") {
|
|
169
|
+
const originalIds = (original.tool_calls ?? []).map((call) => call.id).join("\n");
|
|
170
|
+
const compressedIds = (compressed.tool_calls ?? []).map((call) => call.id).join("\n");
|
|
171
|
+
if (originalIds !== compressedIds) return { ok: false, reason: "assistant-tool-calls-changed" };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function extractOpenAIText(message: OpenAIMessage): string {
|
|
177
|
+
if (message.role === "assistant") return typeof message.content === "string" ? message.content : "";
|
|
178
|
+
if (message.role === "tool" || message.role === "system") return message.content;
|
|
179
|
+
if (typeof message.content === "string") return message.content;
|
|
180
|
+
return message.content
|
|
181
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
182
|
+
.map((part) => part.text)
|
|
183
|
+
.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractTextFromContent(content: unknown): string {
|
|
187
|
+
if (typeof content === "string") return content;
|
|
188
|
+
if (!Array.isArray(content)) return "";
|
|
189
|
+
return content
|
|
190
|
+
.filter((part): part is TextContent => isTextContent(part))
|
|
191
|
+
.map((part) => part.text)
|
|
192
|
+
.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function replaceTextContent(message: MessageWithContent, text: string): boolean {
|
|
196
|
+
if (typeof message.content === "string") {
|
|
197
|
+
message.content = text;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
if (!Array.isArray(message.content)) return false;
|
|
201
|
+
const imageParts = message.content.filter((part): part is ImageContent => isImageContent(part));
|
|
202
|
+
message.content = [{ type: "text", text }, ...imageParts];
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function hasContent(message: AnyMessage): message is AnyMessage & MessageWithContent {
|
|
207
|
+
return "content" in message;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isTextContent(value: unknown): value is TextContent {
|
|
211
|
+
return isRecord(value) && value.type === "text" && typeof value.text === "string";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isImageContent(value: unknown): value is ImageContent {
|
|
215
|
+
return (
|
|
216
|
+
isRecord(value) && value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isToolCall(value: unknown): value is ToolCall {
|
|
221
|
+
return (
|
|
222
|
+
isRecord(value) &&
|
|
223
|
+
value.type === "toolCall" &&
|
|
224
|
+
typeof value.id === "string" &&
|
|
225
|
+
typeof value.name === "string" &&
|
|
226
|
+
isRecord(value.arguments)
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readStringProperty(value: unknown, key: string): string | undefined {
|
|
231
|
+
if (!isRecord(value)) return undefined;
|
|
232
|
+
const property = value[key];
|
|
233
|
+
return typeof property === "string" ? property : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
237
|
+
return typeof value === "object" && value !== null;
|
|
238
|
+
}
|
package/client.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { CompressResult, OpenAIMessage } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
interface ProxyCompressResponse {
|
|
4
|
+
messages: OpenAIMessage[];
|
|
5
|
+
tokens_before: number;
|
|
6
|
+
tokens_after: number;
|
|
7
|
+
tokens_saved: number;
|
|
8
|
+
compression_ratio: number;
|
|
9
|
+
transforms_applied: string[];
|
|
10
|
+
ccr_hashes?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HeadroomClientOptions {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
timeoutMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class HeadroomHttpClient {
|
|
19
|
+
private readonly baseUrl: string;
|
|
20
|
+
private readonly timeoutMs: number;
|
|
21
|
+
|
|
22
|
+
constructor(options: HeadroomClientOptions) {
|
|
23
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
24
|
+
this.timeoutMs = options.timeoutMs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async health(signal?: AbortSignal): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(`${this.baseUrl}/health`, {
|
|
30
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) return false;
|
|
33
|
+
const body = await readJsonObject(response);
|
|
34
|
+
if (!body) return true;
|
|
35
|
+
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async stats(signal?: AbortSignal): Promise<unknown> {
|
|
42
|
+
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
43
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Headroom /stats failed with HTTP ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
return response.json();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
|
|
52
|
+
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
"X-Headroom-Stack": "pi-extension",
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ messages, model: model || "gpt-4o" }),
|
|
59
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const message = await readErrorMessage(response);
|
|
64
|
+
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const payload = (await response.json()) as ProxyCompressResponse;
|
|
68
|
+
return {
|
|
69
|
+
messages: payload.messages,
|
|
70
|
+
tokensBefore: payload.tokens_before,
|
|
71
|
+
tokensAfter: payload.tokens_after,
|
|
72
|
+
tokensSaved: payload.tokens_saved,
|
|
73
|
+
compressionRatio: payload.compression_ratio,
|
|
74
|
+
transformsApplied: payload.transforms_applied ?? [],
|
|
75
|
+
ccrHashes: payload.ccr_hashes ?? [],
|
|
76
|
+
compressed: true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
|
|
82
|
+
try {
|
|
83
|
+
const body = (await response.json()) as unknown;
|
|
84
|
+
return isRecord(body) ? body : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readErrorMessage(response: Response): Promise<string | undefined> {
|
|
91
|
+
try {
|
|
92
|
+
const body = (await response.json()) as unknown;
|
|
93
|
+
if (!isRecord(body)) return undefined;
|
|
94
|
+
const error = body.error;
|
|
95
|
+
if (isRecord(error) && typeof error.message === "string") return error.message;
|
|
96
|
+
if (typeof body.message === "string") return body.message;
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore malformed error bodies.
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
104
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
105
|
+
if (!signal) return timeoutSignal;
|
|
106
|
+
if (signal.aborted) return signal;
|
|
107
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
111
|
+
return typeof value === "object" && value !== null;
|
|
112
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { HeadroomConfig } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
4
|
+
const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
|
|
5
|
+
const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
export function loadHeadroomConfig(env: NodeJS.ProcessEnv = process.env): HeadroomConfig {
|
|
9
|
+
const baseUrl = normalizeBaseUrl(
|
|
10
|
+
env.PI_HEADROOM_URL || env.HEADROOM_URL || env.HEADROOM_BASE_URL || DEFAULT_BASE_URL,
|
|
11
|
+
);
|
|
12
|
+
return {
|
|
13
|
+
enabled: parseBoolean(env.PI_HEADROOM_ENABLED, true),
|
|
14
|
+
baseUrl,
|
|
15
|
+
allowRemote: parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false),
|
|
16
|
+
autoStart: parseBoolean(env.PI_HEADROOM_AUTO_START, true),
|
|
17
|
+
command: env.PI_HEADROOM_COMMAND?.trim() || "headroom",
|
|
18
|
+
minContextTokens: parseInteger(env.PI_HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0),
|
|
19
|
+
minMessageChars: parseInteger(env.PI_HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1),
|
|
20
|
+
timeoutMs: parseInteger(env.PI_HEADROOM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isLocalHeadroomUrl(rawUrl: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(rawUrl);
|
|
27
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isRemoteBlocked(config: Pick<HeadroomConfig, "baseUrl" | "allowRemote">): boolean {
|
|
34
|
+
return !config.allowRemote && !isLocalHeadroomUrl(config.baseUrl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeBaseUrl(raw: string): string {
|
|
38
|
+
const trimmed = raw.trim() || DEFAULT_BASE_URL;
|
|
39
|
+
return trimmed.replace(/\/+$/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseBoolean(raw: string | undefined, fallback: boolean): boolean {
|
|
43
|
+
if (raw === undefined) return fallback;
|
|
44
|
+
const normalized = raw.trim().toLowerCase();
|
|
45
|
+
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
46
|
+
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseInteger(raw: string | undefined, fallback: number, min: number): number {
|
|
51
|
+
if (raw === undefined) return fallback;
|
|
52
|
+
const parsed = Number.parseInt(raw, 10);
|
|
53
|
+
if (!Number.isFinite(parsed) || parsed < min) return fallback;
|
|
54
|
+
return parsed;
|
|
55
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { applyCompressionResult, buildCompressionPayload } from "./bridge.ts";
|
|
3
|
+
import { HeadroomHttpClient } from "./client.ts";
|
|
4
|
+
import { isRemoteBlocked, loadHeadroomConfig } from "./config.ts";
|
|
5
|
+
import { startPersistentHeadroomProxy } from "./proxy-manager.ts";
|
|
6
|
+
import type { AgentMessage, CompressResult, HeadroomConfig, HeadroomStats } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const STATUS_KEY = "headroom";
|
|
9
|
+
const SUBCOMMANDS = ["status", "on", "off", "health", "stats"] as const;
|
|
10
|
+
|
|
11
|
+
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
12
|
+
|
|
13
|
+
interface HeadroomRuntimeState {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
proxyOnline: boolean | null;
|
|
16
|
+
proxyStarting: boolean;
|
|
17
|
+
proxyStartAttempted: boolean;
|
|
18
|
+
remoteWarningShown: boolean;
|
|
19
|
+
offlineWarningShown: boolean;
|
|
20
|
+
stats: HeadroomStats;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface HeadroomRuntime {
|
|
24
|
+
config: HeadroomConfig;
|
|
25
|
+
client: HeadroomHttpClient;
|
|
26
|
+
state: HeadroomRuntimeState;
|
|
27
|
+
refreshStatus(ctx: ExtensionContext): void;
|
|
28
|
+
updateHealth(ctx: ExtensionContext): Promise<boolean>;
|
|
29
|
+
ensureProxy(ctx: ExtensionContext): Promise<boolean>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function headroomExtension(pi: ExtensionAPI) {
|
|
33
|
+
const runtime = createRuntime();
|
|
34
|
+
|
|
35
|
+
pi.on("session_start", (_event, ctx) => {
|
|
36
|
+
if (isRemoteBlocked(runtime.config)) {
|
|
37
|
+
runtime.refreshStatus(ctx);
|
|
38
|
+
ctx.ui.notify(
|
|
39
|
+
`Headroom remote URL is blocked by default: ${runtime.config.baseUrl}\nSet PI_HEADROOM_ALLOW_REMOTE=1 only if you trust that proxy with full context.`,
|
|
40
|
+
"warning",
|
|
41
|
+
);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
runtime.refreshStatus(ctx);
|
|
45
|
+
if (!runtime.state.enabled) return;
|
|
46
|
+
void ensureProxyInBackground(runtime, ctx);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
pi.on("context", (event, ctx) => handleContextCompression(runtime, event, ctx));
|
|
50
|
+
|
|
51
|
+
pi.registerCommand("headroom", {
|
|
52
|
+
description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats]",
|
|
53
|
+
getArgumentCompletions(argumentPrefix) {
|
|
54
|
+
const prefix = argumentPrefix.trim().toLowerCase();
|
|
55
|
+
return SUBCOMMANDS.filter((command) => command.startsWith(prefix)).map((command) => ({
|
|
56
|
+
value: command,
|
|
57
|
+
label: command,
|
|
58
|
+
}));
|
|
59
|
+
},
|
|
60
|
+
handler: async (args, ctx) => handleCommand(runtime, parseSubcommand(args), ctx),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
pi.registerCommand("headroom-health", {
|
|
64
|
+
description: "Check Headroom proxy health",
|
|
65
|
+
handler: async (_args, ctx) => {
|
|
66
|
+
await handleCommand(runtime, "health", ctx);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createRuntime(): HeadroomRuntime {
|
|
72
|
+
const config = loadHeadroomConfig();
|
|
73
|
+
const client = new HeadroomHttpClient({ baseUrl: config.baseUrl, timeoutMs: config.timeoutMs });
|
|
74
|
+
const state: HeadroomRuntimeState = {
|
|
75
|
+
enabled: config.enabled,
|
|
76
|
+
proxyOnline: null,
|
|
77
|
+
proxyStarting: false,
|
|
78
|
+
proxyStartAttempted: false,
|
|
79
|
+
remoteWarningShown: false,
|
|
80
|
+
offlineWarningShown: false,
|
|
81
|
+
stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const runtime: HeadroomRuntime = {
|
|
85
|
+
config,
|
|
86
|
+
client,
|
|
87
|
+
state,
|
|
88
|
+
refreshStatus(ctx) {
|
|
89
|
+
refreshStatus(ctx, runtime.config, runtime.state);
|
|
90
|
+
},
|
|
91
|
+
async updateHealth(ctx) {
|
|
92
|
+
const online = await updateHealthState(runtime, ctx.signal);
|
|
93
|
+
runtime.refreshStatus(ctx);
|
|
94
|
+
return online;
|
|
95
|
+
},
|
|
96
|
+
async ensureProxy(ctx) {
|
|
97
|
+
return ensureProxy(runtime, ctx);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return runtime;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function updateHealthState(runtime: HeadroomRuntime, signal?: AbortSignal): Promise<boolean> {
|
|
104
|
+
if (isRemoteBlocked(runtime.config)) return false;
|
|
105
|
+
runtime.state.proxyOnline = await runtime.client.health(signal);
|
|
106
|
+
return runtime.state.proxyOnline;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function ensureProxy(runtime: HeadroomRuntime, ctx: ExtensionContext): Promise<boolean> {
|
|
110
|
+
if (await runtime.updateHealth(ctx)) return true;
|
|
111
|
+
if (!runtime.config.autoStart || runtime.state.proxyStartAttempted) return false;
|
|
112
|
+
|
|
113
|
+
runtime.state.proxyStartAttempted = true;
|
|
114
|
+
runtime.state.proxyStarting = true;
|
|
115
|
+
runtime.refreshStatus(ctx);
|
|
116
|
+
const started = await startPersistentHeadroomProxy(runtime.config);
|
|
117
|
+
if (!started.ok) {
|
|
118
|
+
runtime.state.stats.lastError = started.reason;
|
|
119
|
+
runtime.state.proxyStarting = false;
|
|
120
|
+
runtime.state.proxyOnline = false;
|
|
121
|
+
runtime.refreshStatus(ctx);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const online = await waitForProxyHealth(runtime, ctx.signal);
|
|
126
|
+
runtime.state.proxyStarting = false;
|
|
127
|
+
runtime.state.proxyOnline = online;
|
|
128
|
+
runtime.refreshStatus(ctx);
|
|
129
|
+
return online;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function ensureProxyInBackground(runtime: HeadroomRuntime, ctx?: ExtensionContext): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
if (await updateHealthState(runtime)) {
|
|
135
|
+
safeRefreshStatus(runtime, ctx);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!runtime.config.autoStart || runtime.state.proxyStartAttempted) {
|
|
139
|
+
safeRefreshStatus(runtime, ctx);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
runtime.state.proxyStartAttempted = true;
|
|
143
|
+
runtime.state.proxyStarting = true;
|
|
144
|
+
safeRefreshStatus(runtime, ctx);
|
|
145
|
+
const started = await startPersistentHeadroomProxy(runtime.config);
|
|
146
|
+
if (!started.ok) {
|
|
147
|
+
runtime.state.stats.lastError = started.reason;
|
|
148
|
+
runtime.state.proxyStarting = false;
|
|
149
|
+
runtime.state.proxyOnline = false;
|
|
150
|
+
safeRefreshStatus(runtime, ctx);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
runtime.state.proxyOnline = await waitForProxyHealth(runtime);
|
|
154
|
+
runtime.state.proxyStarting = false;
|
|
155
|
+
safeRefreshStatus(runtime, ctx);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
runtime.state.proxyStarting = false;
|
|
158
|
+
runtime.state.proxyOnline = false;
|
|
159
|
+
runtime.state.stats.lastError = error instanceof Error ? error.message : String(error);
|
|
160
|
+
safeRefreshStatus(runtime, ctx);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeRefreshStatus(runtime: HeadroomRuntime, ctx: ExtensionContext | undefined): void {
|
|
165
|
+
if (!ctx) return;
|
|
166
|
+
try {
|
|
167
|
+
runtime.refreshStatus(ctx);
|
|
168
|
+
} catch {
|
|
169
|
+
// The session may have been reloaded/replaced while background health was in flight.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function waitForProxyHealth(runtime: HeadroomRuntime, signal?: AbortSignal): Promise<boolean> {
|
|
174
|
+
for (const delay of [300, 500, 800, 1200, 2000]) {
|
|
175
|
+
await sleep(delay);
|
|
176
|
+
if (await updateHealthState(runtime, signal)) return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sleep(ms: number): Promise<void> {
|
|
182
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleContextCompression(
|
|
186
|
+
runtime: HeadroomRuntime,
|
|
187
|
+
event: ContextEvent,
|
|
188
|
+
ctx: ExtensionContext,
|
|
189
|
+
): Promise<{ messages?: AgentMessage[] } | undefined> {
|
|
190
|
+
if (shouldSkipBeforePayload(runtime, ctx)) return undefined;
|
|
191
|
+
const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
|
|
192
|
+
if (payload.candidateCount === 0) return undefined;
|
|
193
|
+
if (runtime.state.proxyOnline !== true) {
|
|
194
|
+
void ensureProxyInBackground(runtime, ctx);
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
runtime.state.stats.attempts++;
|
|
199
|
+
try {
|
|
200
|
+
const result = await runtime.client.compress(payload.messages, ctx.model?.id, ctx.signal);
|
|
201
|
+
runtime.state.proxyOnline = true;
|
|
202
|
+
if (!result.compressed || result.tokensSaved <= 0) {
|
|
203
|
+
runtime.refreshStatus(ctx);
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const applied = applyCompressionResult(event.messages, payload.mappings, result.messages, {
|
|
208
|
+
minMessageChars: runtime.config.minMessageChars,
|
|
209
|
+
});
|
|
210
|
+
if (!applied.ok) {
|
|
211
|
+
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
212
|
+
runtime.refreshStatus(ctx);
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
|
|
217
|
+
runtime.refreshStatus(ctx);
|
|
218
|
+
return { messages: applied.messages };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
recordCompressionError(runtime, ctx, error);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function shouldSkipBeforePayload(runtime: HeadroomRuntime, ctx: ExtensionContext): boolean {
|
|
226
|
+
if (!runtime.state.enabled) return true;
|
|
227
|
+
if (isRemoteBlocked(runtime.config)) {
|
|
228
|
+
if (!runtime.state.remoteWarningShown) {
|
|
229
|
+
runtime.state.remoteWarningShown = true;
|
|
230
|
+
ctx.ui.notify("Headroom compression skipped because remote proxy is blocked.", "warning");
|
|
231
|
+
}
|
|
232
|
+
runtime.refreshStatus(ctx);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
const usage = ctx.getContextUsage();
|
|
236
|
+
return usage?.tokens !== null && usage?.tokens !== undefined && usage.tokens < runtime.config.minContextTokens;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function recordGuardSkip(stats: HeadroomStats, reason: string): void {
|
|
240
|
+
stats.guardSkips++;
|
|
241
|
+
stats.lastSkipReason = reason;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function recordAppliedCompression(stats: HeadroomStats, result: CompressResult, appliedMessages: number): void {
|
|
245
|
+
stats.applied++;
|
|
246
|
+
stats.tokensSaved += result.tokensSaved;
|
|
247
|
+
stats.lastError = undefined;
|
|
248
|
+
stats.lastSkipReason = undefined;
|
|
249
|
+
stats.last = { ...result, appliedMessages };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function recordCompressionError(runtime: HeadroomRuntime, ctx: ExtensionContext, error: unknown): void {
|
|
253
|
+
runtime.state.proxyOnline = false;
|
|
254
|
+
runtime.state.stats.lastError = error instanceof Error ? error.message : String(error);
|
|
255
|
+
if (!runtime.state.offlineWarningShown) {
|
|
256
|
+
runtime.state.offlineWarningShown = true;
|
|
257
|
+
ctx.ui.notify(
|
|
258
|
+
`Headroom proxy unavailable. Compression disabled until /headroom health succeeds.\n${runtime.state.stats.lastError}`,
|
|
259
|
+
"warning",
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
runtime.refreshStatus(ctx);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx: ExtensionContext): Promise<void> {
|
|
266
|
+
if (command === "on") {
|
|
267
|
+
runtime.state.enabled = true;
|
|
268
|
+
runtime.state.offlineWarningShown = false;
|
|
269
|
+
runtime.state.proxyStartAttempted = false;
|
|
270
|
+
const healthy = await runtime.ensureProxy(ctx);
|
|
271
|
+
ctx.ui.notify(
|
|
272
|
+
healthy
|
|
273
|
+
? "Headroom compression enabled. Proxy will keep running after Pi exits."
|
|
274
|
+
: proxyStartHint(runtime.config),
|
|
275
|
+
healthy ? "info" : "warning",
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (command === "off") {
|
|
280
|
+
runtime.state.enabled = false;
|
|
281
|
+
runtime.refreshStatus(ctx);
|
|
282
|
+
ctx.ui.notify("Headroom compression disabled for this Pi session. The proxy process is left running.", "info");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (command === "health") {
|
|
286
|
+
runtime.state.proxyStartAttempted = false;
|
|
287
|
+
const healthy = await runtime.ensureProxy(ctx);
|
|
288
|
+
ctx.ui.notify(
|
|
289
|
+
healthy ? `Headroom proxy online: ${runtime.config.baseUrl}` : proxyStartHint(runtime.config),
|
|
290
|
+
healthy ? "info" : "warning",
|
|
291
|
+
);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (command === "stats") {
|
|
295
|
+
await showProxyStats(ctx, runtime.client, runtime.config);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function refreshStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): void {
|
|
302
|
+
if (!ctx.hasUI) return;
|
|
303
|
+
ctx.ui.setStatus(STATUS_KEY, renderFooterStatus(ctx, config, state));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): string {
|
|
307
|
+
const theme = ctx.ui.theme;
|
|
308
|
+
if (!state.enabled) return theme.fg("dim", "○ Headroom off");
|
|
309
|
+
if (isRemoteBlocked(config)) return theme.fg("warning", "⚠") + theme.fg("dim", " Headroom remote blocked");
|
|
310
|
+
if (state.proxyStarting) return theme.fg("dim", "⏳ Headroom starting");
|
|
311
|
+
if (state.proxyOnline === false) return theme.fg("dim", "○ Headroom not running");
|
|
312
|
+
if (state.proxyOnline === null && !state.stats.last) return theme.fg("dim", "○ Headroom idle");
|
|
313
|
+
if (!state.stats.last) return theme.fg("success", "✓") + theme.fg("dim", " Headroom");
|
|
314
|
+
|
|
315
|
+
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
316
|
+
return (
|
|
317
|
+
theme.fg("success", "✓") +
|
|
318
|
+
theme.fg("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function showProxyStats(
|
|
323
|
+
ctx: ExtensionContext,
|
|
324
|
+
client: HeadroomHttpClient,
|
|
325
|
+
config: HeadroomConfig,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
if (isRemoteBlocked(config)) {
|
|
328
|
+
ctx.ui.notify(renderRemoteBlocked(config), "warning");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const stats = await client.stats(ctx.signal);
|
|
333
|
+
ctx.ui.notify(
|
|
334
|
+
`Headroom proxy stats (${config.baseUrl}):\n${JSON.stringify(stats, null, 2).slice(0, 4000)}`,
|
|
335
|
+
"info",
|
|
336
|
+
);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
339
|
+
ctx.ui.notify(`Could not read Headroom stats: ${message}`, "warning");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): string {
|
|
344
|
+
const stats = state.stats;
|
|
345
|
+
const lines = [
|
|
346
|
+
"Headroom token compression",
|
|
347
|
+
` Enabled: ${state.enabled ? "yes" : "no"}`,
|
|
348
|
+
` Proxy: ${config.baseUrl} (${state.proxyOnline === true ? "online" : state.proxyStarting ? "starting" : state.proxyOnline === false ? "not running" : "unknown"})`,
|
|
349
|
+
` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
|
|
350
|
+
` Shutdown: proxy is left running after Pi exits`,
|
|
351
|
+
` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
|
|
352
|
+
` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
|
|
353
|
+
"",
|
|
354
|
+
"Session stats:",
|
|
355
|
+
` Attempts: ${stats.attempts}`,
|
|
356
|
+
` Applied: ${stats.applied}`,
|
|
357
|
+
` Guard skips: ${stats.guardSkips}`,
|
|
358
|
+
` Tokens saved: ${stats.tokensSaved.toLocaleString()}`,
|
|
359
|
+
];
|
|
360
|
+
if (stats.last) {
|
|
361
|
+
const pct = Math.round((1 - stats.last.compressionRatio) * 100);
|
|
362
|
+
lines.push(
|
|
363
|
+
"",
|
|
364
|
+
"Last applied compression:",
|
|
365
|
+
` ${stats.last.tokensBefore.toLocaleString()} → ${stats.last.tokensAfter.toLocaleString()} tokens (-${pct}%)`,
|
|
366
|
+
` Applied messages: ${stats.last.appliedMessages}`,
|
|
367
|
+
` Transforms: ${stats.last.transformsApplied.join(", ") || "none"}`,
|
|
368
|
+
` CCR hashes: ${stats.last.ccrHashes.length}`,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (stats.lastSkipReason) lines.push("", `Last guard skip: ${stats.lastSkipReason}`);
|
|
372
|
+
if (stats.lastError) lines.push("", `Last error: ${stats.lastError}`);
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function proxyStartHint(config: HeadroomConfig): string {
|
|
377
|
+
if (isRemoteBlocked(config)) return renderRemoteBlocked(config);
|
|
378
|
+
if (!config.autoStart) {
|
|
379
|
+
return [
|
|
380
|
+
`Headroom proxy is not running: ${config.baseUrl}`,
|
|
381
|
+
"Auto-start is disabled. Start it manually:",
|
|
382
|
+
` HEADROOM_TELEMETRY=off ${renderManualProxyCommand(config)}`,
|
|
383
|
+
].join("\n");
|
|
384
|
+
}
|
|
385
|
+
return [
|
|
386
|
+
`Headroom proxy is not running: ${config.baseUrl}`,
|
|
387
|
+
`Tried to start persistent proxy with command: ${config.command}`,
|
|
388
|
+
"Install Headroom or set PI_HEADROOM_COMMAND if needed:",
|
|
389
|
+
' pip install "headroom-ai[proxy]"',
|
|
390
|
+
" # then run /headroom on",
|
|
391
|
+
].join("\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function renderManualProxyCommand(config: HeadroomConfig): string {
|
|
395
|
+
try {
|
|
396
|
+
const url = new URL(config.baseUrl);
|
|
397
|
+
const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
|
|
398
|
+
const port = url.port || "8788";
|
|
399
|
+
return `${config.command} proxy --host ${host} --port ${port} --mode token --no-cache`;
|
|
400
|
+
} catch {
|
|
401
|
+
return `${config.command} proxy --mode token --no-cache`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderRemoteBlocked(config: HeadroomConfig): string {
|
|
406
|
+
return [
|
|
407
|
+
`Headroom remote URL is blocked: ${config.baseUrl}`,
|
|
408
|
+
"Compression sends conversation context to the proxy.",
|
|
409
|
+
"Set PI_HEADROOM_ALLOW_REMOTE=1 only for a trusted proxy.",
|
|
410
|
+
].join("\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseSubcommand(args: string): Subcommand {
|
|
414
|
+
const normalized = args.trim().toLowerCase();
|
|
415
|
+
return SUBCOMMANDS.includes(normalized as Subcommand) ? (normalized as Subcommand) : "status";
|
|
416
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-headroom",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headroom token compression for pi — compress large tool results via a local Headroom proxy to reclaim context window.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Jonghakseo/pi-extension.git",
|
|
9
|
+
"directory": "packages/headroom"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/Jonghakseo/pi-extension/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/Jonghakseo/pi-extension/tree/main/packages/headroom#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"types.ts",
|
|
22
|
+
"config.ts",
|
|
23
|
+
"bridge.ts",
|
|
24
|
+
"client.ts",
|
|
25
|
+
"proxy-manager.ts",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-ai": "*",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@earendil-works/pi-ai": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@earendil-works/pi-coding-agent": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/proxy-manager.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import type { HeadroomConfig } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface ProxyEndpoint {
|
|
5
|
+
host: string;
|
|
6
|
+
port: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function startPersistentHeadroomProxy(
|
|
10
|
+
config: HeadroomConfig,
|
|
11
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
12
|
+
const endpoint = parseLocalEndpoint(config.baseUrl);
|
|
13
|
+
if (!endpoint) return { ok: false, reason: "unsupported-local-url" };
|
|
14
|
+
|
|
15
|
+
const available = await isCommandAvailable(config.command);
|
|
16
|
+
if (!available) return { ok: false, reason: `command-not-found:${config.command}` };
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(config.command, buildProxyArgs(endpoint), {
|
|
20
|
+
detached: true,
|
|
21
|
+
stdio: "ignore",
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
child.unref();
|
|
28
|
+
return { ok: true };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildProxyArgs(endpoint: ProxyEndpoint): string[] {
|
|
35
|
+
return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "token", "--no-cache"];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function parseLocalEndpoint(baseUrl: string): ProxyEndpoint | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(baseUrl);
|
|
41
|
+
if (!["http:", "https:"].includes(url.protocol)) return undefined;
|
|
42
|
+
if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)) return undefined;
|
|
43
|
+
const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
|
|
44
|
+
const port = url.port || "8787";
|
|
45
|
+
return { host, port };
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isCommandAvailable(command: string): Promise<boolean> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = execFile(command, ["--help"], { timeout: 1500 }, (error) => {
|
|
54
|
+
resolve(!error);
|
|
55
|
+
});
|
|
56
|
+
child.on("error", () => resolve(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// AgentMessage lives in @earendil-works/pi-agent-core, which is only a transitive
|
|
4
|
+
// dependency here. Derive it from the publicly exported ContextEvent payload so the
|
|
5
|
+
// package depends solely on @earendil-works/pi-coding-agent + @earendil-works/pi-ai.
|
|
6
|
+
export type AgentMessage = ContextEvent["messages"][number];
|
|
7
|
+
|
|
8
|
+
export interface TextContentPart {
|
|
9
|
+
type: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ImageContentPart {
|
|
14
|
+
type: "image_url";
|
|
15
|
+
image_url: { url: string; detail?: "auto" | "low" | "high" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type OpenAIContentPart = TextContentPart | ImageContentPart;
|
|
19
|
+
|
|
20
|
+
export interface OpenAISystemMessage {
|
|
21
|
+
role: "system";
|
|
22
|
+
content: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OpenAIUserMessage {
|
|
26
|
+
role: "user";
|
|
27
|
+
content: string | OpenAIContentPart[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OpenAIToolCall {
|
|
31
|
+
id: string;
|
|
32
|
+
type: "function";
|
|
33
|
+
function: { name: string; arguments: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OpenAIAssistantMessage {
|
|
37
|
+
role: "assistant";
|
|
38
|
+
content: string | null;
|
|
39
|
+
tool_calls?: OpenAIToolCall[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OpenAIToolMessage {
|
|
43
|
+
role: "tool";
|
|
44
|
+
content: string;
|
|
45
|
+
tool_call_id: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type OpenAIMessage = OpenAISystemMessage | OpenAIUserMessage | OpenAIAssistantMessage | OpenAIToolMessage;
|
|
49
|
+
|
|
50
|
+
export interface CompressResult {
|
|
51
|
+
messages: OpenAIMessage[];
|
|
52
|
+
tokensBefore: number;
|
|
53
|
+
tokensAfter: number;
|
|
54
|
+
tokensSaved: number;
|
|
55
|
+
compressionRatio: number;
|
|
56
|
+
transformsApplied: string[];
|
|
57
|
+
ccrHashes: string[];
|
|
58
|
+
compressed: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface HeadroomConfig {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
baseUrl: string;
|
|
64
|
+
allowRemote: boolean;
|
|
65
|
+
autoStart: boolean;
|
|
66
|
+
command: string;
|
|
67
|
+
minContextTokens: number;
|
|
68
|
+
minMessageChars: number;
|
|
69
|
+
timeoutMs: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HeadroomStats {
|
|
73
|
+
attempts: number;
|
|
74
|
+
applied: number;
|
|
75
|
+
guardSkips: number;
|
|
76
|
+
tokensSaved: number;
|
|
77
|
+
last?: {
|
|
78
|
+
tokensBefore: number;
|
|
79
|
+
tokensAfter: number;
|
|
80
|
+
tokensSaved: number;
|
|
81
|
+
compressionRatio: number;
|
|
82
|
+
transformsApplied: string[];
|
|
83
|
+
ccrHashes: string[];
|
|
84
|
+
appliedMessages: number;
|
|
85
|
+
};
|
|
86
|
+
lastError?: string;
|
|
87
|
+
lastSkipReason?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CompressionMapping {
|
|
91
|
+
sourceIndex: number;
|
|
92
|
+
message: OpenAIMessage;
|
|
93
|
+
applyTo: "toolResult" | null;
|
|
94
|
+
originalText: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CompressionPayload {
|
|
98
|
+
messages: OpenAIMessage[];
|
|
99
|
+
mappings: CompressionMapping[];
|
|
100
|
+
candidateCount: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ApplyCompressionOptions {
|
|
104
|
+
minMessageChars: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type ApplyCompressionResult =
|
|
108
|
+
| { ok: true; messages: AgentMessage[]; appliedMessages: number }
|
|
109
|
+
| { ok: false; reason: string };
|