@raquezha/noheadroom 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +24 -0
- package/README.md +91 -0
- package/UPSTREAM-README.md +68 -0
- package/dist/bridge.d.ts +3 -0
- package/dist/bridge.js +202 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.js +95 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +78 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +431 -0
- package/dist/proxy-manager.d.ts +13 -0
- package/dist/proxy-manager.js +50 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.js +1 -0
- package/extensions/bridge.ts +241 -0
- package/extensions/client.ts +112 -0
- package/extensions/config.ts +101 -0
- package/extensions/index.ts +527 -0
- package/extensions/proxy-manager.ts +58 -0
- package/extensions/types.ts +109 -0
- package/package.json +42 -0
- package/tsconfig.json +15 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
This package is derived from @ryan_nookpi/pi-extension-headroom by Ryan/Jonghakseo, MIT licensed.
|
|
4
|
+
|
|
5
|
+
Copyright for original portions belongs to the original authors.
|
|
6
|
+
Modifications are copyright raquezha.
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# noheadroom 🗜
|
|
2
|
+
|
|
3
|
+
> **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroom-ai/headroom).
|
|
4
|
+
|
|
5
|
+
`noheadroom` sits between Pi and your LLM, shrinking massive tool results and logs before they reach the model. Save tokens, keep more history, and prevent context-overflow in long sessions.
|
|
6
|
+
|
|
7
|
+
## 🚀 Why noheadroom?
|
|
8
|
+
|
|
9
|
+
Upstream Headroom protects common agent tool names like `read` and `bash` by default. In a standard Pi workflow, this means large file reads often bypass compression entirely.
|
|
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%** |
|
|
17
|
+
|
|
18
|
+
## ✨ Features
|
|
19
|
+
|
|
20
|
+
- **Adaptive Payload Sanitization**: renames tool calls during compression to ensure Headroom actually shrinks them.
|
|
21
|
+
- **Pi-Native Metadata Preservation**: original tool IDs and names are never modified in your real session.
|
|
22
|
+
- **Deep Visibility**: compression results appear in your terminal, the Pi footer, and as persistent entries in your session history.
|
|
23
|
+
- **Docker-First Architecture**: designed to work seamlessly with a local containerized backend.
|
|
24
|
+
- **Local-First Privacy**: by default, context never leaves your machine.
|
|
25
|
+
|
|
26
|
+
## 📦 Installation
|
|
27
|
+
|
|
28
|
+
### Within the `nothing` Monorepo
|
|
29
|
+
|
|
30
|
+
`noheadroom` is built-in. Start Pi with compression enabled:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pi --headroom
|
|
34
|
+
# OR full tokenmaxxing:
|
|
35
|
+
pi --tkmx
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Standalone (NPM)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install npm:@raquezha/noheadroom
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 🛠 Usage
|
|
45
|
+
|
|
46
|
+
### Backend Setup
|
|
47
|
+
|
|
48
|
+
`noheadroom` requires a Headroom proxy running on `127.0.0.1:8788`. Use the provided scripts in the `nothing` repo:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
./scripts/headroom-up.sh # Launch Docker backend
|
|
52
|
+
./scripts/headroom-health.sh # Verify connection
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Commands
|
|
56
|
+
|
|
57
|
+
Inside Pi, use the `/headroom` command:
|
|
58
|
+
|
|
59
|
+
- `/headroom` — Session statistics and status summary.
|
|
60
|
+
- `/headroom on` | `off` — Toggle compression live.
|
|
61
|
+
- `/headroom health` — Check if the backend is alive.
|
|
62
|
+
- `/headroom stats` — Inspect raw backend metrics.
|
|
63
|
+
|
|
64
|
+
## 🔧 Configuration
|
|
65
|
+
|
|
66
|
+
Settings are stored in `~/.pi/agent/headroom/settings.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"baseUrl": "http://127.0.0.1:8788",
|
|
72
|
+
"autoStart": false,
|
|
73
|
+
"minContextTokens": 10000,
|
|
74
|
+
"minMessageChars": 2000
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **`autoStart`**: Set to `false` when using the Docker backend.
|
|
79
|
+
- **`minContextTokens`**: Compression kicks in once the context reaches this size.
|
|
80
|
+
|
|
81
|
+
## 🛡 Privacy & Security
|
|
82
|
+
|
|
83
|
+
Context is sent only to `localhost` (`127.0.0.1`) by default. Remote proxies are strictly blocked unless `PI_HEADROOM_ALLOW_REMOTE=1` is explicitly set in your environment.
|
|
84
|
+
|
|
85
|
+
## 🤝 Attribution
|
|
86
|
+
|
|
87
|
+
This project is a fork of [@ryan_nookpi/pi-extension-headroom](https://github.com/Jonghakseo/pi-extension/tree/main/packages/headroom) by [Ryan/Jonghakseo](https://github.com/Jonghakseo), modified to support Pi-specific tool-result adaptation. Licensed under MIT.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
**[nothing](https://github.com/raquezha/nothing)** — Local-first agentic development setup.
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
Settings are read at startup from `~/.pi/agent/headroom/settings.json`. Values in this file override environment variables; environment variables remain supported as fallbacks.
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"minContextTokens": 10000,
|
|
53
|
+
"minMessageChars": 1000
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Setting key | Env fallback | Default | Description |
|
|
58
|
+
| --- | --- | --- | --- |
|
|
59
|
+
| `enabled` | `PI_HEADROOM_ENABLED` | `true` | Enable compression on start. |
|
|
60
|
+
| `baseUrl` (`url` also accepted) | `PI_HEADROOM_URL` (`HEADROOM_URL` / `HEADROOM_BASE_URL` also accepted) | `http://127.0.0.1:8788` | Proxy base URL. |
|
|
61
|
+
| `allowRemote` | `PI_HEADROOM_ALLOW_REMOTE` | `false` | Allow non-local proxy URLs. |
|
|
62
|
+
| `autoStart` | `PI_HEADROOM_AUTO_START` | `true` | Auto-start a local persistent proxy when offline. |
|
|
63
|
+
| `command` | `PI_HEADROOM_COMMAND` | `headroom` | Command used to launch the proxy. |
|
|
64
|
+
| `minContextTokens` | `PI_HEADROOM_MIN_CONTEXT_TOKENS` | `20000` | Skip compression below this context token count. |
|
|
65
|
+
| `minMessageChars` | `PI_HEADROOM_MIN_MESSAGE_CHARS` | `2000` | Only compress tool results at or above this size. |
|
|
66
|
+
| `timeoutMs` | `PI_HEADROOM_TIMEOUT_MS` | `30000` | HTTP timeout for proxy requests. |
|
|
67
|
+
|
|
68
|
+
Boolean values accept JSON booleans, or strings such as `1/0`, `true/false`, `yes/no`, `on/off`.
|
package/dist/bridge.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { AgentMessage, ApplyCompressionOptions, ApplyCompressionResult, CompressionMapping, CompressionPayload, OpenAIMessage } from "./types.js";
|
|
2
|
+
export declare function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload;
|
|
3
|
+
export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], _options: ApplyCompressionOptions): ApplyCompressionResult;
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
|
|
2
|
+
export function buildCompressionPayload(messages, minMessageChars) {
|
|
3
|
+
const mappings = [];
|
|
4
|
+
let candidateCount = 0;
|
|
5
|
+
for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
|
|
6
|
+
const source = messages[sourceIndex];
|
|
7
|
+
const converted = convertMessage(source);
|
|
8
|
+
if (!converted)
|
|
9
|
+
continue;
|
|
10
|
+
const originalText = extractOpenAIText(converted);
|
|
11
|
+
const applyTo = source.role === "toolResult" && originalText.length >= minMessageChars ? "toolResult" : null;
|
|
12
|
+
if (applyTo)
|
|
13
|
+
candidateCount++;
|
|
14
|
+
mappings.push({ sourceIndex, message: converted, applyTo, originalText });
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
messages: mappings.map((mapping) => mapping.message),
|
|
18
|
+
mappings,
|
|
19
|
+
candidateCount,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function applyCompressionResult(originalMessages, mappings, compressedMessages, _options) {
|
|
23
|
+
if (compressedMessages.length !== mappings.length) {
|
|
24
|
+
return { ok: false, reason: "message-count-changed" };
|
|
25
|
+
}
|
|
26
|
+
const nextMessages = structuredClone(originalMessages);
|
|
27
|
+
let appliedMessages = 0;
|
|
28
|
+
for (let index = 0; index < mappings.length; index++) {
|
|
29
|
+
const mapping = mappings[index];
|
|
30
|
+
const compressed = compressedMessages[index];
|
|
31
|
+
const validation = validateAlignedMessage(mapping.message, compressed);
|
|
32
|
+
if (!validation.ok)
|
|
33
|
+
return validation;
|
|
34
|
+
const nextText = extractOpenAIText(compressed);
|
|
35
|
+
if (nextText === mapping.originalText)
|
|
36
|
+
continue;
|
|
37
|
+
if (mapping.applyTo !== "toolResult") {
|
|
38
|
+
return { ok: false, reason: `non-candidate-changed:${mapping.message.role}` };
|
|
39
|
+
}
|
|
40
|
+
const target = nextMessages[mapping.sourceIndex];
|
|
41
|
+
if (target.role !== "toolResult") {
|
|
42
|
+
return { ok: false, reason: "source-role-mismatch" };
|
|
43
|
+
}
|
|
44
|
+
if (!replaceTextContent(target, nextText)) {
|
|
45
|
+
return { ok: false, reason: "target-content-unreplaceable" };
|
|
46
|
+
}
|
|
47
|
+
appliedMessages++;
|
|
48
|
+
}
|
|
49
|
+
if (appliedMessages === 0) {
|
|
50
|
+
return { ok: false, reason: "no-applicable-message-changed" };
|
|
51
|
+
}
|
|
52
|
+
return { ok: true, messages: nextMessages, appliedMessages };
|
|
53
|
+
}
|
|
54
|
+
function convertMessage(message) {
|
|
55
|
+
if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role))
|
|
56
|
+
return undefined;
|
|
57
|
+
switch (message.role) {
|
|
58
|
+
case "user":
|
|
59
|
+
return convertUserMessage(message);
|
|
60
|
+
case "assistant":
|
|
61
|
+
return convertAssistantMessage(message);
|
|
62
|
+
case "toolResult":
|
|
63
|
+
return convertToolResultMessage(message);
|
|
64
|
+
default:
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function convertUserMessage(message) {
|
|
69
|
+
if (!hasContent(message))
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof message.content === "string") {
|
|
72
|
+
return { role: "user", content: message.content };
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(message.content))
|
|
75
|
+
return undefined;
|
|
76
|
+
const text = message.content
|
|
77
|
+
.filter((part) => isTextContent(part))
|
|
78
|
+
.map((part) => part.text)
|
|
79
|
+
.join("\n");
|
|
80
|
+
if (text)
|
|
81
|
+
return { role: "user", content: text };
|
|
82
|
+
const hasImages = message.content.some((part) => isImageContent(part));
|
|
83
|
+
return hasImages ? { role: "user", content: "[image omitted from Headroom compression payload]" } : undefined;
|
|
84
|
+
}
|
|
85
|
+
function convertAssistantMessage(message) {
|
|
86
|
+
if (!hasContent(message) || !Array.isArray(message.content))
|
|
87
|
+
return undefined;
|
|
88
|
+
const text = message.content
|
|
89
|
+
.filter((part) => isTextContent(part))
|
|
90
|
+
.map((part) => part.text)
|
|
91
|
+
.join("");
|
|
92
|
+
const toolCalls = message.content.filter((part) => isToolCall(part));
|
|
93
|
+
if (!text && toolCalls.length === 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const converted = {
|
|
96
|
+
role: "assistant",
|
|
97
|
+
content: text || null,
|
|
98
|
+
};
|
|
99
|
+
if (toolCalls.length > 0) {
|
|
100
|
+
converted.tool_calls = toolCalls.map(convertToolCall);
|
|
101
|
+
}
|
|
102
|
+
return converted;
|
|
103
|
+
}
|
|
104
|
+
function convertToolResultMessage(message) {
|
|
105
|
+
if (!hasContent(message))
|
|
106
|
+
return undefined;
|
|
107
|
+
const toolCallId = readStringProperty(message, "toolCallId");
|
|
108
|
+
if (!toolCallId)
|
|
109
|
+
return undefined;
|
|
110
|
+
return {
|
|
111
|
+
role: "tool",
|
|
112
|
+
content: extractTextFromContent(message.content),
|
|
113
|
+
tool_call_id: toolCallId,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function convertToolCall(toolCall) {
|
|
117
|
+
return {
|
|
118
|
+
id: toolCall.id,
|
|
119
|
+
type: "function",
|
|
120
|
+
function: {
|
|
121
|
+
// Headroom may protect/exclude built-in agent tool names such as `read` and `bash`.
|
|
122
|
+
// This payload is only for compression alignment; Pi's original tool metadata is preserved
|
|
123
|
+
// in originalMessages and restored when applying the compressed toolResult text.
|
|
124
|
+
name: "pi_tool_result",
|
|
125
|
+
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function validateAlignedMessage(original, compressed) {
|
|
130
|
+
if (original.role !== compressed.role) {
|
|
131
|
+
return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
|
|
132
|
+
}
|
|
133
|
+
if (original.role === "tool" && compressed.role === "tool") {
|
|
134
|
+
if (original.tool_call_id !== compressed.tool_call_id)
|
|
135
|
+
return { ok: false, reason: "tool-call-id-changed" };
|
|
136
|
+
}
|
|
137
|
+
if (original.role === "assistant" && compressed.role === "assistant") {
|
|
138
|
+
const originalIds = (original.tool_calls ?? []).map((call) => call.id).join("\n");
|
|
139
|
+
const compressedIds = (compressed.tool_calls ?? []).map((call) => call.id).join("\n");
|
|
140
|
+
if (originalIds !== compressedIds)
|
|
141
|
+
return { ok: false, reason: "assistant-tool-calls-changed" };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
function extractOpenAIText(message) {
|
|
146
|
+
if (message.role === "assistant")
|
|
147
|
+
return typeof message.content === "string" ? message.content : "";
|
|
148
|
+
if (message.role === "tool" || message.role === "system")
|
|
149
|
+
return message.content;
|
|
150
|
+
if (typeof message.content === "string")
|
|
151
|
+
return message.content;
|
|
152
|
+
return message.content
|
|
153
|
+
.filter((part) => part.type === "text")
|
|
154
|
+
.map((part) => part.text)
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
157
|
+
function extractTextFromContent(content) {
|
|
158
|
+
if (typeof content === "string")
|
|
159
|
+
return content;
|
|
160
|
+
if (!Array.isArray(content))
|
|
161
|
+
return "";
|
|
162
|
+
return content
|
|
163
|
+
.filter((part) => isTextContent(part))
|
|
164
|
+
.map((part) => part.text)
|
|
165
|
+
.join("\n");
|
|
166
|
+
}
|
|
167
|
+
function replaceTextContent(message, text) {
|
|
168
|
+
if (typeof message.content === "string") {
|
|
169
|
+
message.content = text;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (!Array.isArray(message.content))
|
|
173
|
+
return false;
|
|
174
|
+
const imageParts = message.content.filter((part) => isImageContent(part));
|
|
175
|
+
message.content = [{ type: "text", text }, ...imageParts];
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
function hasContent(message) {
|
|
179
|
+
return "content" in message;
|
|
180
|
+
}
|
|
181
|
+
function isTextContent(value) {
|
|
182
|
+
return isRecord(value) && value.type === "text" && typeof value.text === "string";
|
|
183
|
+
}
|
|
184
|
+
function isImageContent(value) {
|
|
185
|
+
return (isRecord(value) && value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string");
|
|
186
|
+
}
|
|
187
|
+
function isToolCall(value) {
|
|
188
|
+
return (isRecord(value) &&
|
|
189
|
+
value.type === "toolCall" &&
|
|
190
|
+
typeof value.id === "string" &&
|
|
191
|
+
typeof value.name === "string" &&
|
|
192
|
+
isRecord(value.arguments));
|
|
193
|
+
}
|
|
194
|
+
function readStringProperty(value, key) {
|
|
195
|
+
if (!isRecord(value))
|
|
196
|
+
return undefined;
|
|
197
|
+
const property = value[key];
|
|
198
|
+
return typeof property === "string" ? property : undefined;
|
|
199
|
+
}
|
|
200
|
+
function isRecord(value) {
|
|
201
|
+
return typeof value === "object" && value !== null;
|
|
202
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CompressResult, OpenAIMessage } from "./types.js";
|
|
2
|
+
interface HeadroomClientOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class HeadroomHttpClient {
|
|
7
|
+
private readonly baseUrl;
|
|
8
|
+
private readonly timeoutMs;
|
|
9
|
+
constructor(options: HeadroomClientOptions);
|
|
10
|
+
health(signal?: AbortSignal): Promise<boolean>;
|
|
11
|
+
stats(signal?: AbortSignal): Promise<unknown>;
|
|
12
|
+
compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult>;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export class HeadroomHttpClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
timeoutMs;
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
6
|
+
this.timeoutMs = options.timeoutMs;
|
|
7
|
+
}
|
|
8
|
+
async health(signal) {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetch(`${this.baseUrl}/health`, {
|
|
11
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
return false;
|
|
15
|
+
const body = await readJsonObject(response);
|
|
16
|
+
if (!body)
|
|
17
|
+
return true;
|
|
18
|
+
return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async stats(signal) {
|
|
25
|
+
const response = await fetch(`${this.baseUrl}/stats`, {
|
|
26
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`Headroom /stats failed with HTTP ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
return response.json();
|
|
32
|
+
}
|
|
33
|
+
async compress(messages, model, signal) {
|
|
34
|
+
const response = await fetch(`${this.baseUrl}/v1/compress`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: {
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"X-Headroom-Stack": "pi-extension",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ messages, model: model || "gpt-4o" }),
|
|
41
|
+
signal: buildSignal(this.timeoutMs, signal),
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const message = await readErrorMessage(response);
|
|
45
|
+
throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
|
|
46
|
+
}
|
|
47
|
+
const payload = (await response.json());
|
|
48
|
+
return {
|
|
49
|
+
messages: payload.messages,
|
|
50
|
+
tokensBefore: payload.tokens_before,
|
|
51
|
+
tokensAfter: payload.tokens_after,
|
|
52
|
+
tokensSaved: payload.tokens_saved,
|
|
53
|
+
compressionRatio: payload.compression_ratio,
|
|
54
|
+
transformsApplied: payload.transforms_applied ?? [],
|
|
55
|
+
ccrHashes: payload.ccr_hashes ?? [],
|
|
56
|
+
compressed: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function readJsonObject(response) {
|
|
61
|
+
try {
|
|
62
|
+
const body = (await response.json());
|
|
63
|
+
return isRecord(body) ? body : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function readErrorMessage(response) {
|
|
70
|
+
try {
|
|
71
|
+
const body = (await response.json());
|
|
72
|
+
if (!isRecord(body))
|
|
73
|
+
return undefined;
|
|
74
|
+
const error = body.error;
|
|
75
|
+
if (isRecord(error) && typeof error.message === "string")
|
|
76
|
+
return error.message;
|
|
77
|
+
if (typeof body.message === "string")
|
|
78
|
+
return body.message;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Ignore malformed error bodies.
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function buildSignal(timeoutMs, signal) {
|
|
86
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
87
|
+
if (!signal)
|
|
88
|
+
return timeoutSignal;
|
|
89
|
+
if (signal.aborted)
|
|
90
|
+
return signal;
|
|
91
|
+
return AbortSignal.any([signal, timeoutSignal]);
|
|
92
|
+
}
|
|
93
|
+
function isRecord(value) {
|
|
94
|
+
return typeof value === "object" && value !== null;
|
|
95
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HeadroomConfig } from "./types.js";
|
|
2
|
+
export declare const HEADROOM_SETTINGS_DIR: string;
|
|
3
|
+
export declare const HEADROOM_SETTINGS_FILE: string;
|
|
4
|
+
export interface HeadroomSettings {
|
|
5
|
+
enabled?: boolean | string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
allowRemote?: boolean | string;
|
|
9
|
+
autoStart?: boolean | string;
|
|
10
|
+
command?: string;
|
|
11
|
+
minContextTokens?: number | string;
|
|
12
|
+
minMessageChars?: number | string;
|
|
13
|
+
timeoutMs?: number | string;
|
|
14
|
+
}
|
|
15
|
+
export declare function loadHeadroomSettings(settingsPath?: string): HeadroomSettings;
|
|
16
|
+
export declare function loadHeadroomConfig(env?: NodeJS.ProcessEnv, settings?: HeadroomSettings): HeadroomConfig;
|
|
17
|
+
export declare function isLocalHeadroomUrl(rawUrl: string): boolean;
|
|
18
|
+
export declare function isRemoteBlocked(config: Pick<HeadroomConfig, "baseUrl" | "allowRemote">): boolean;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
5
|
+
const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
|
|
6
|
+
const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
export const HEADROOM_SETTINGS_DIR = path.join(os.homedir(), ".pi", "agent", "headroom");
|
|
9
|
+
export const HEADROOM_SETTINGS_FILE = path.join(HEADROOM_SETTINGS_DIR, "settings.json");
|
|
10
|
+
export function loadHeadroomSettings(settingsPath = HEADROOM_SETTINGS_FILE) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(settingsPath, "utf-8");
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Missing or invalid settings.json falls back to env/defaults.
|
|
19
|
+
}
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
export function loadHeadroomConfig(env = process.env, settings = env === process.env ? loadHeadroomSettings() : {}) {
|
|
23
|
+
const envBaseUrl = env.PI_HEADROOM_URL || env.HEADROOM_URL || env.HEADROOM_BASE_URL || DEFAULT_BASE_URL;
|
|
24
|
+
const baseUrl = normalizeBaseUrl(parseString(settings.baseUrl ?? settings.url, envBaseUrl));
|
|
25
|
+
return {
|
|
26
|
+
enabled: parseBoolean(settings.enabled, parseBoolean(env.PI_HEADROOM_ENABLED, true)),
|
|
27
|
+
baseUrl,
|
|
28
|
+
allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
|
|
29
|
+
autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
|
|
30
|
+
command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
|
|
31
|
+
minContextTokens: parseInteger(settings.minContextTokens, parseInteger(env.PI_HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0), 0),
|
|
32
|
+
minMessageChars: parseInteger(settings.minMessageChars, parseInteger(env.PI_HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1), 1),
|
|
33
|
+
timeoutMs: parseInteger(settings.timeoutMs, parseInteger(env.PI_HEADROOM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100), 100),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function isLocalHeadroomUrl(rawUrl) {
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(rawUrl);
|
|
39
|
+
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function isRemoteBlocked(config) {
|
|
46
|
+
return !config.allowRemote && !isLocalHeadroomUrl(config.baseUrl);
|
|
47
|
+
}
|
|
48
|
+
function normalizeBaseUrl(raw) {
|
|
49
|
+
const trimmed = raw.trim() || DEFAULT_BASE_URL;
|
|
50
|
+
return trimmed.replace(/\/+$/, "");
|
|
51
|
+
}
|
|
52
|
+
function parseString(raw, fallback) {
|
|
53
|
+
if (typeof raw !== "string")
|
|
54
|
+
return fallback;
|
|
55
|
+
return raw.trim() || fallback;
|
|
56
|
+
}
|
|
57
|
+
function parseBoolean(raw, fallback) {
|
|
58
|
+
if (raw === undefined)
|
|
59
|
+
return fallback;
|
|
60
|
+
if (typeof raw === "boolean")
|
|
61
|
+
return raw;
|
|
62
|
+
if (typeof raw !== "string")
|
|
63
|
+
return fallback;
|
|
64
|
+
const normalized = raw.trim().toLowerCase();
|
|
65
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
66
|
+
return true;
|
|
67
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
68
|
+
return false;
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
function parseInteger(raw, fallback, min) {
|
|
72
|
+
if (raw === undefined)
|
|
73
|
+
return fallback;
|
|
74
|
+
const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
|
|
75
|
+
if (!Number.isFinite(parsed) || parsed < min)
|
|
76
|
+
return fallback;
|
|
77
|
+
return Math.trunc(parsed);
|
|
78
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { HeadroomConfig, HeadroomStats } from "./types.js";
|
|
3
|
+
interface HeadroomRuntimeState {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
proxyOnline: boolean | null;
|
|
6
|
+
proxyStarting: boolean;
|
|
7
|
+
proxyStartAttempted: boolean;
|
|
8
|
+
remoteWarningShown: boolean;
|
|
9
|
+
offlineWarningShown: boolean;
|
|
10
|
+
stats: HeadroomStats;
|
|
11
|
+
}
|
|
12
|
+
export default function headroomExtension(pi: ExtensionAPI): void;
|
|
13
|
+
declare function isAbortOrTimeoutError(error: unknown): boolean;
|
|
14
|
+
declare function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): string;
|
|
15
|
+
export declare const __test__: {
|
|
16
|
+
isAbortOrTimeoutError: typeof isAbortOrTimeoutError;
|
|
17
|
+
renderFooterStatus: typeof renderFooterStatus;
|
|
18
|
+
};
|
|
19
|
+
export {};
|