@raquezha/noheadroom 0.2.4 → 0.3.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 +2 -0
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.js +25 -3
- package/dist/config.d.ts +3 -0
- package/dist/config.js +22 -0
- package/dist/index.js +45 -10
- package/dist/types.d.ts +2 -0
- package/extensions/bridge.ts +32 -3
- package/extensions/config.ts +33 -1
- package/extensions/index.ts +48 -10
- package/extensions/types.ts +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,12 +73,14 @@ Settings are stored in `~/.pi/agent/headroom/settings.json`:
|
|
|
73
73
|
"enabled": true,
|
|
74
74
|
"baseUrl": "http://127.0.0.1:8788",
|
|
75
75
|
"autoStart": false,
|
|
76
|
+
"mode": "normal",
|
|
76
77
|
"minContextTokens": 10000,
|
|
77
78
|
"minMessageChars": 2000
|
|
78
79
|
}
|
|
79
80
|
```
|
|
80
81
|
|
|
81
82
|
- **`autoStart`**: Set to `false` when using the Docker backend.
|
|
83
|
+
- **`mode`**: `normal` (all output), `quiet` (suppress routine success), or `silent` (suppress all non-critical notices). Override with `PI_HEADROOM_MODE=quiet`.
|
|
82
84
|
- **`minContextTokens`**: Compression kicks in once the context reaches this size.
|
|
83
85
|
|
|
84
86
|
## 🛡 Privacy & Security
|
package/dist/bridge.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export declare function buildCompressionPayload(messages: AgentMessage[], minMes
|
|
|
8
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[];
|
|
11
12
|
export {};
|
package/dist/bridge.js
CHANGED
|
@@ -46,6 +46,8 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
|
|
|
46
46
|
let nextText = extractOpenAIText(compressed);
|
|
47
47
|
if (nextText === mapping.originalText)
|
|
48
48
|
continue;
|
|
49
|
+
// Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
|
|
50
|
+
nextText = naturalizeHeadroomMarkers(nextText);
|
|
49
51
|
const target = nextMessages[mapping.sourceIndex];
|
|
50
52
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
51
53
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
@@ -124,9 +126,8 @@ function convertToolCall(toolCall) {
|
|
|
124
126
|
id: toolCall.id,
|
|
125
127
|
type: "function",
|
|
126
128
|
function: {
|
|
127
|
-
// Headroom
|
|
128
|
-
//
|
|
129
|
-
// in originalMessages and restored when applying the compressed toolResult text.
|
|
129
|
+
// Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
|
|
130
|
+
// Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
|
|
130
131
|
name: "pi_tool_result",
|
|
131
132
|
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
132
133
|
},
|
|
@@ -220,3 +221,24 @@ function readStringProperty(value, key) {
|
|
|
220
221
|
function isRecord(value) {
|
|
221
222
|
return typeof value === "object" && value !== null;
|
|
222
223
|
}
|
|
224
|
+
function naturalizeHeadroomMarkers(text) {
|
|
225
|
+
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
226
|
+
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
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;
|
|
244
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,12 +7,15 @@ export interface HeadroomSettings {
|
|
|
7
7
|
url?: string;
|
|
8
8
|
allowRemote?: boolean | string;
|
|
9
9
|
autoStart?: boolean | string;
|
|
10
|
+
mode?: string;
|
|
11
|
+
silent?: boolean | string;
|
|
10
12
|
command?: string;
|
|
11
13
|
minContextTokens?: number | string;
|
|
12
14
|
minMessageChars?: number | string;
|
|
13
15
|
timeoutMs?: number | string;
|
|
14
16
|
}
|
|
15
17
|
export declare function loadHeadroomSettings(settingsPath?: string): HeadroomSettings;
|
|
18
|
+
export declare function saveHeadroomSettings(patch: Partial<HeadroomSettings>, settingsPath?: string): void;
|
|
16
19
|
export declare function loadHeadroomConfig(env?: NodeJS.ProcessEnv, settings?: HeadroomSettings): HeadroomConfig;
|
|
17
20
|
export declare function isLocalHeadroomUrl(rawUrl: string): boolean;
|
|
18
21
|
export declare function isRemoteBlocked(config: Pick<HeadroomConfig, "baseUrl" | "allowRemote">): boolean;
|
package/dist/config.js
CHANGED
|
@@ -19,6 +19,17 @@ export function loadHeadroomSettings(settingsPath = HEADROOM_SETTINGS_FILE) {
|
|
|
19
19
|
}
|
|
20
20
|
return {};
|
|
21
21
|
}
|
|
22
|
+
export function saveHeadroomSettings(patch, settingsPath = HEADROOM_SETTINGS_FILE) {
|
|
23
|
+
const current = loadHeadroomSettings(settingsPath);
|
|
24
|
+
const updated = { ...current, ...patch };
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.warn(`[noheadroom] Failed to save settings to ${settingsPath}:`, error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
22
33
|
export function loadHeadroomConfig(env = process.env, settings = env === process.env ? loadHeadroomSettings() : {}) {
|
|
23
34
|
const envBaseUrl = env.PI_HEADROOM_URL || env.HEADROOM_URL || env.HEADROOM_BASE_URL || DEFAULT_BASE_URL;
|
|
24
35
|
const baseUrl = normalizeBaseUrl(parseString(settings.baseUrl ?? settings.url, envBaseUrl));
|
|
@@ -27,6 +38,7 @@ export function loadHeadroomConfig(env = process.env, settings = env === process
|
|
|
27
38
|
baseUrl,
|
|
28
39
|
allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
|
|
29
40
|
autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
|
|
41
|
+
mode: parseMode(settings.mode, env.PI_HEADROOM_MODE, parseBoolean(settings.silent, parseBoolean(env.PI_HEADROOM_SILENT, false)) ? "silent" : "normal"),
|
|
30
42
|
command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
|
|
31
43
|
minContextTokens: parseInteger(settings.minContextTokens, parseInteger(env.PI_HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0), 0),
|
|
32
44
|
minMessageChars: parseInteger(settings.minMessageChars, parseInteger(env.PI_HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1), 1),
|
|
@@ -76,3 +88,13 @@ function parseInteger(raw, fallback, min) {
|
|
|
76
88
|
return fallback;
|
|
77
89
|
return Math.trunc(parsed);
|
|
78
90
|
}
|
|
91
|
+
const HEADROOM_MODES = ["normal", "quiet", "silent"];
|
|
92
|
+
function parseMode(raw, envRaw, fallback) {
|
|
93
|
+
const normalize = (v) => {
|
|
94
|
+
if (typeof v !== "string")
|
|
95
|
+
return null;
|
|
96
|
+
const s = v.trim().toLowerCase();
|
|
97
|
+
return HEADROOM_MODES.includes(s) ? s : null;
|
|
98
|
+
};
|
|
99
|
+
return normalize(raw) ?? normalize(envRaw) ?? fallback;
|
|
100
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,11 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { applyCompressionResult, buildCompressionPayload, convertMessage, extractOpenAIText, } from "./bridge.js";
|
|
4
4
|
import { HeadroomHttpClient } from "./client.js";
|
|
5
|
-
import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
|
|
5
|
+
import { isRemoteBlocked, loadHeadroomConfig, saveHeadroomSettings } from "./config.js";
|
|
6
6
|
import { startPersistentHeadroomProxy } from "./proxy-manager.js";
|
|
7
7
|
const STATUS_KEY = "headroom";
|
|
8
|
-
const SUBCOMMANDS = ["status", "on", "off", "health", "stats"];
|
|
8
|
+
const SUBCOMMANDS = ["status", "on", "off", "health", "stats", "mode"];
|
|
9
|
+
const MODES = ["normal", "quiet", "silent"];
|
|
9
10
|
const NOTRACE_TELEMETRY_CHANNEL = "notrace.telemetry.extension";
|
|
10
11
|
const COMPRESSION_ENTRY_TYPE = "noheadroom.compression";
|
|
11
12
|
export default function headroomExtension(pi) {
|
|
@@ -26,15 +27,30 @@ export default function headroomExtension(pi) {
|
|
|
26
27
|
});
|
|
27
28
|
pi.on("context", (event, ctx) => handleContextCompression(runtime, event, ctx));
|
|
28
29
|
pi.registerCommand("headroom", {
|
|
29
|
-
description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats]",
|
|
30
|
+
description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats|mode <normal|quiet|silent>]",
|
|
30
31
|
getArgumentCompletions(argumentPrefix) {
|
|
31
|
-
const
|
|
32
|
+
const parts = argumentPrefix.trim().toLowerCase().split(/\s+/);
|
|
33
|
+
if (parts[0] === "mode") {
|
|
34
|
+
const modePfx = parts[1] ?? "";
|
|
35
|
+
return MODES.filter((m) => m.startsWith(modePfx)).map((m) => ({
|
|
36
|
+
value: `mode ${m}`,
|
|
37
|
+
label: `mode ${m}`,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
const prefix = parts[0] ?? "";
|
|
32
41
|
return SUBCOMMANDS.filter((command) => command.startsWith(prefix)).map((command) => ({
|
|
33
42
|
value: command,
|
|
34
43
|
label: command,
|
|
35
44
|
}));
|
|
36
45
|
},
|
|
37
|
-
handler: async (args, ctx) =>
|
|
46
|
+
handler: async (args, ctx) => {
|
|
47
|
+
const parts = args.trim().split(/\s+/);
|
|
48
|
+
const first = (parts[0] ?? "").toLowerCase();
|
|
49
|
+
if (first === "mode") {
|
|
50
|
+
return handleModeChange(runtime, parts[1]?.toLowerCase() ?? "", ctx);
|
|
51
|
+
}
|
|
52
|
+
return handleCommand(runtime, parseSubcommand(first), ctx);
|
|
53
|
+
},
|
|
38
54
|
});
|
|
39
55
|
pi.registerCommand("headroom-health", {
|
|
40
56
|
description: "Check Headroom proxy health",
|
|
@@ -226,6 +242,8 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
226
242
|
// until the actual compressible candidate material changes.
|
|
227
243
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
228
244
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
245
|
+
// ponytail: clear stats.last so renderFooterStatus shows idle instead of stale savings
|
|
246
|
+
runtime.state.stats.last = undefined;
|
|
229
247
|
runtime.refreshStatus(ctx);
|
|
230
248
|
return undefined;
|
|
231
249
|
}
|
|
@@ -239,7 +257,7 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
239
257
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
240
258
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
241
259
|
emitNotraceTelemetry(runtime);
|
|
242
|
-
announceGuardSkip(ctx, applied.reason, result);
|
|
260
|
+
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
243
261
|
runtime.refreshStatus(ctx);
|
|
244
262
|
return undefined;
|
|
245
263
|
}
|
|
@@ -249,7 +267,7 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
249
267
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
250
268
|
recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
|
|
251
269
|
emitNotraceTelemetry(runtime);
|
|
252
|
-
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
|
|
270
|
+
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
|
|
253
271
|
runtime.refreshStatus(ctx);
|
|
254
272
|
return { messages: applied.messages };
|
|
255
273
|
}
|
|
@@ -379,7 +397,9 @@ function emitNotraceTelemetry(runtime) {
|
|
|
379
397
|
// Telemetry should never break compression behavior.
|
|
380
398
|
}
|
|
381
399
|
}
|
|
382
|
-
function announceAppliedCompression(pi, ctx, result, appliedMessages) {
|
|
400
|
+
function announceAppliedCompression(pi, ctx, result, appliedMessages, mode = "normal") {
|
|
401
|
+
if (mode === "quiet" || mode === "silent")
|
|
402
|
+
return;
|
|
383
403
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
384
404
|
const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
|
|
385
405
|
const line = `noheadroom: ${summary}`;
|
|
@@ -391,7 +411,9 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages) {
|
|
|
391
411
|
if (!ctx.hasUI)
|
|
392
412
|
process.stderr.write(`🗜 ${line}\n`);
|
|
393
413
|
}
|
|
394
|
-
function announceGuardSkip(ctx, reason, result) {
|
|
414
|
+
function announceGuardSkip(ctx, reason, result, mode = "normal") {
|
|
415
|
+
if (mode === "silent")
|
|
416
|
+
return;
|
|
395
417
|
const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
|
|
396
418
|
ctx.ui.notify(line, "warning");
|
|
397
419
|
// In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
|
|
@@ -460,6 +482,18 @@ async function handleCommand(runtime, command, ctx) {
|
|
|
460
482
|
}
|
|
461
483
|
ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
|
|
462
484
|
}
|
|
485
|
+
async function handleModeChange(runtime, rawMode, ctx) {
|
|
486
|
+
const VALID_MODES = ["normal", "quiet", "silent"];
|
|
487
|
+
if (!VALID_MODES.includes(rawMode)) {
|
|
488
|
+
ctx.ui.notify(`Unknown mode "${rawMode}". Valid modes: ${VALID_MODES.join(", ")}\nUsage: /headroom mode <normal|quiet|silent>`, "warning");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const mode = rawMode;
|
|
492
|
+
runtime.config.mode = mode;
|
|
493
|
+
saveHeadroomSettings({ mode });
|
|
494
|
+
emitNotraceTelemetry(runtime);
|
|
495
|
+
ctx.ui.notify(`Headroom output mode set to "${mode}" and saved to settings.`, "info");
|
|
496
|
+
}
|
|
463
497
|
function refreshStatus(ctx, config, state) {
|
|
464
498
|
if (!ctx.hasUI)
|
|
465
499
|
return;
|
|
@@ -488,7 +522,7 @@ function renderFooterStatus(ctx, config, state) {
|
|
|
488
522
|
if (!state.stats.last)
|
|
489
523
|
return paint("success", "✓") + paint("dim", " Headroom");
|
|
490
524
|
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
491
|
-
return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()}
|
|
525
|
+
return (paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`));
|
|
492
526
|
}
|
|
493
527
|
async function showProxyStats(ctx, client, config) {
|
|
494
528
|
if (isRemoteBlocked(config)) {
|
|
@@ -513,6 +547,7 @@ function renderStatus(config, state) {
|
|
|
513
547
|
` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
|
|
514
548
|
` Shutdown: proxy is left running after Pi exits`,
|
|
515
549
|
` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
|
|
550
|
+
` Mode: ${config.mode}`,
|
|
516
551
|
` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
|
|
517
552
|
"",
|
|
518
553
|
"Session stats:",
|
package/dist/types.d.ts
CHANGED
|
@@ -49,11 +49,13 @@ export interface CompressResult {
|
|
|
49
49
|
ccrHashes: string[];
|
|
50
50
|
compressed: boolean;
|
|
51
51
|
}
|
|
52
|
+
export type HeadroomMode = "normal" | "quiet" | "silent";
|
|
52
53
|
export interface HeadroomConfig {
|
|
53
54
|
enabled: boolean;
|
|
54
55
|
baseUrl: string;
|
|
55
56
|
allowRemote: boolean;
|
|
56
57
|
autoStart: boolean;
|
|
58
|
+
mode: HeadroomMode;
|
|
57
59
|
command: string;
|
|
58
60
|
minContextTokens: number;
|
|
59
61
|
minMessageChars: number;
|
package/extensions/bridge.ts
CHANGED
|
@@ -77,6 +77,9 @@ export function applyCompressionResult(
|
|
|
77
77
|
let nextText = extractOpenAIText(compressed);
|
|
78
78
|
if (nextText === mapping.originalText) continue;
|
|
79
79
|
|
|
80
|
+
// Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
|
|
81
|
+
nextText = naturalizeHeadroomMarkers(nextText);
|
|
82
|
+
|
|
80
83
|
const target = nextMessages[mapping.sourceIndex] as AnyMessage;
|
|
81
84
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
82
85
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
@@ -156,9 +159,8 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
|
|
|
156
159
|
id: toolCall.id,
|
|
157
160
|
type: "function",
|
|
158
161
|
function: {
|
|
159
|
-
// Headroom
|
|
160
|
-
//
|
|
161
|
-
// in originalMessages and restored when applying the compressed toolResult text.
|
|
162
|
+
// Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
|
|
163
|
+
// Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
|
|
162
164
|
name: "pi_tool_result",
|
|
163
165
|
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
164
166
|
},
|
|
@@ -264,3 +266,30 @@ function readStringProperty(value: unknown, key: string): string | undefined {
|
|
|
264
266
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
265
267
|
return typeof value === "object" && value !== null;
|
|
266
268
|
}
|
|
269
|
+
|
|
270
|
+
function naturalizeHeadroomMarkers(text: string): string {
|
|
271
|
+
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
272
|
+
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
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;
|
|
295
|
+
}
|
package/extensions/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import type { HeadroomConfig } from "./types.js";
|
|
4
|
+
import type { HeadroomConfig, HeadroomMode } from "./types.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
7
7
|
const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
|
|
@@ -17,6 +17,8 @@ export interface HeadroomSettings {
|
|
|
17
17
|
url?: string;
|
|
18
18
|
allowRemote?: boolean | string;
|
|
19
19
|
autoStart?: boolean | string;
|
|
20
|
+
mode?: string;
|
|
21
|
+
silent?: boolean | string;
|
|
20
22
|
command?: string;
|
|
21
23
|
minContextTokens?: number | string;
|
|
22
24
|
minMessageChars?: number | string;
|
|
@@ -34,6 +36,20 @@ export function loadHeadroomSettings(settingsPath: string = HEADROOM_SETTINGS_FI
|
|
|
34
36
|
return {};
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export function saveHeadroomSettings(
|
|
40
|
+
patch: Partial<HeadroomSettings>,
|
|
41
|
+
settingsPath: string = HEADROOM_SETTINGS_FILE,
|
|
42
|
+
): void {
|
|
43
|
+
const current = loadHeadroomSettings(settingsPath);
|
|
44
|
+
const updated = { ...current, ...patch };
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
47
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn(`[noheadroom] Failed to save settings to ${settingsPath}:`, error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export function loadHeadroomConfig(
|
|
38
54
|
env: NodeJS.ProcessEnv = process.env,
|
|
39
55
|
settings: HeadroomSettings = env === process.env ? loadHeadroomSettings() : {},
|
|
@@ -45,6 +61,11 @@ export function loadHeadroomConfig(
|
|
|
45
61
|
baseUrl,
|
|
46
62
|
allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
|
|
47
63
|
autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
|
|
64
|
+
mode: parseMode(
|
|
65
|
+
settings.mode,
|
|
66
|
+
env.PI_HEADROOM_MODE,
|
|
67
|
+
parseBoolean(settings.silent, parseBoolean(env.PI_HEADROOM_SILENT, false)) ? "silent" : "normal",
|
|
68
|
+
),
|
|
48
69
|
command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
|
|
49
70
|
minContextTokens: parseInteger(
|
|
50
71
|
settings.minContextTokens,
|
|
@@ -99,3 +120,14 @@ function parseInteger(raw: unknown, fallback: number, min: number): number {
|
|
|
99
120
|
if (!Number.isFinite(parsed) || parsed < min) return fallback;
|
|
100
121
|
return Math.trunc(parsed);
|
|
101
122
|
}
|
|
123
|
+
|
|
124
|
+
const HEADROOM_MODES: HeadroomMode[] = ["normal", "quiet", "silent"];
|
|
125
|
+
|
|
126
|
+
function parseMode(raw: unknown, envRaw: string | undefined, fallback: HeadroomMode): HeadroomMode {
|
|
127
|
+
const normalize = (v: unknown): HeadroomMode | null => {
|
|
128
|
+
if (typeof v !== "string") return null;
|
|
129
|
+
const s = v.trim().toLowerCase() as HeadroomMode;
|
|
130
|
+
return HEADROOM_MODES.includes(s) ? s : null;
|
|
131
|
+
};
|
|
132
|
+
return normalize(raw) ?? normalize(envRaw) ?? fallback;
|
|
133
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -8,12 +8,13 @@ import {
|
|
|
8
8
|
extractOpenAIText,
|
|
9
9
|
} from "./bridge.js";
|
|
10
10
|
import { HeadroomHttpClient } from "./client.js";
|
|
11
|
-
import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
|
|
11
|
+
import { isRemoteBlocked, loadHeadroomConfig, saveHeadroomSettings } from "./config.js";
|
|
12
12
|
import { startPersistentHeadroomProxy } from "./proxy-manager.js";
|
|
13
|
-
import type { AgentMessage, CompressResult, CompressionPayload, HeadroomConfig, HeadroomStats } from "./types.js";
|
|
13
|
+
import type { AgentMessage, CompressResult, CompressionPayload, HeadroomConfig, HeadroomMode, HeadroomStats } from "./types.js";
|
|
14
14
|
|
|
15
15
|
const STATUS_KEY = "headroom";
|
|
16
|
-
const SUBCOMMANDS = ["status", "on", "off", "health", "stats"] as const;
|
|
16
|
+
const SUBCOMMANDS = ["status", "on", "off", "health", "stats", "mode"] as const;
|
|
17
|
+
const MODES = ["normal", "quiet", "silent"] as const;
|
|
17
18
|
const NOTRACE_TELEMETRY_CHANNEL = "notrace.telemetry.extension";
|
|
18
19
|
|
|
19
20
|
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
@@ -78,15 +79,30 @@ export default function headroomExtension(pi: ExtensionAPI) {
|
|
|
78
79
|
pi.on("context", (event, ctx) => handleContextCompression(runtime, event, ctx));
|
|
79
80
|
|
|
80
81
|
pi.registerCommand("headroom", {
|
|
81
|
-
description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats]",
|
|
82
|
+
description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats|mode <normal|quiet|silent>]",
|
|
82
83
|
getArgumentCompletions(argumentPrefix) {
|
|
83
|
-
const
|
|
84
|
+
const parts = argumentPrefix.trim().toLowerCase().split(/\s+/);
|
|
85
|
+
if (parts[0] === "mode") {
|
|
86
|
+
const modePfx = parts[1] ?? "";
|
|
87
|
+
return MODES.filter((m) => m.startsWith(modePfx)).map((m) => ({
|
|
88
|
+
value: `mode ${m}`,
|
|
89
|
+
label: `mode ${m}`,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
const prefix = parts[0] ?? "";
|
|
84
93
|
return SUBCOMMANDS.filter((command) => command.startsWith(prefix)).map((command) => ({
|
|
85
94
|
value: command,
|
|
86
95
|
label: command,
|
|
87
96
|
}));
|
|
88
97
|
},
|
|
89
|
-
handler: async (args, ctx) =>
|
|
98
|
+
handler: async (args, ctx) => {
|
|
99
|
+
const parts = args.trim().split(/\s+/);
|
|
100
|
+
const first = (parts[0] ?? "").toLowerCase();
|
|
101
|
+
if (first === "mode") {
|
|
102
|
+
return handleModeChange(runtime, parts[1]?.toLowerCase() ?? "", ctx);
|
|
103
|
+
}
|
|
104
|
+
return handleCommand(runtime, parseSubcommand(first), ctx);
|
|
105
|
+
},
|
|
90
106
|
});
|
|
91
107
|
|
|
92
108
|
pi.registerCommand("headroom-health", {
|
|
@@ -291,6 +307,8 @@ async function handleContextCompression(
|
|
|
291
307
|
// until the actual compressible candidate material changes.
|
|
292
308
|
runtime.state.lastInputFingerprint = inputFingerprint;
|
|
293
309
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
310
|
+
// ponytail: clear stats.last so renderFooterStatus shows idle instead of stale savings
|
|
311
|
+
runtime.state.stats.last = undefined;
|
|
294
312
|
runtime.refreshStatus(ctx);
|
|
295
313
|
return undefined;
|
|
296
314
|
}
|
|
@@ -306,7 +324,7 @@ async function handleContextCompression(
|
|
|
306
324
|
|
|
307
325
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
308
326
|
emitNotraceTelemetry(runtime);
|
|
309
|
-
announceGuardSkip(ctx, applied.reason, result);
|
|
327
|
+
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
310
328
|
runtime.refreshStatus(ctx);
|
|
311
329
|
return undefined;
|
|
312
330
|
}
|
|
@@ -318,7 +336,7 @@ async function handleContextCompression(
|
|
|
318
336
|
|
|
319
337
|
recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
|
|
320
338
|
emitNotraceTelemetry(runtime);
|
|
321
|
-
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
|
|
339
|
+
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
|
|
322
340
|
runtime.refreshStatus(ctx);
|
|
323
341
|
return { messages: applied.messages };
|
|
324
342
|
} catch (error) {
|
|
@@ -456,7 +474,9 @@ function announceAppliedCompression(
|
|
|
456
474
|
ctx: ExtensionContext,
|
|
457
475
|
result: CompressResult,
|
|
458
476
|
appliedMessages: number,
|
|
477
|
+
mode: HeadroomMode = "normal",
|
|
459
478
|
): void {
|
|
479
|
+
if (mode === "quiet" || mode === "silent") return;
|
|
460
480
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
461
481
|
const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
|
|
462
482
|
const line = `noheadroom: ${summary}`;
|
|
@@ -468,7 +488,8 @@ function announceAppliedCompression(
|
|
|
468
488
|
if (!ctx.hasUI) process.stderr.write(`🗜 ${line}\n`);
|
|
469
489
|
}
|
|
470
490
|
|
|
471
|
-
function announceGuardSkip(ctx: ExtensionContext, reason: string, result: CompressResult): void {
|
|
491
|
+
function announceGuardSkip(ctx: ExtensionContext, reason: string, result: CompressResult, mode: HeadroomMode = "normal"): void {
|
|
492
|
+
if (mode === "silent") return;
|
|
472
493
|
const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
|
|
473
494
|
ctx.ui.notify(line, "warning");
|
|
474
495
|
// In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
|
|
@@ -551,6 +572,22 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
|
|
|
551
572
|
ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
|
|
552
573
|
}
|
|
553
574
|
|
|
575
|
+
async function handleModeChange(runtime: HeadroomRuntime, rawMode: string, ctx: ExtensionContext): Promise<void> {
|
|
576
|
+
const VALID_MODES: HeadroomMode[] = ["normal", "quiet", "silent"];
|
|
577
|
+
if (!VALID_MODES.includes(rawMode as HeadroomMode)) {
|
|
578
|
+
ctx.ui.notify(
|
|
579
|
+
`Unknown mode "${rawMode}". Valid modes: ${VALID_MODES.join(", ")}\nUsage: /headroom mode <normal|quiet|silent>`,
|
|
580
|
+
"warning",
|
|
581
|
+
);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const mode = rawMode as HeadroomMode;
|
|
585
|
+
runtime.config.mode = mode;
|
|
586
|
+
saveHeadroomSettings({ mode });
|
|
587
|
+
emitNotraceTelemetry(runtime);
|
|
588
|
+
ctx.ui.notify(`Headroom output mode set to "${mode}" and saved to settings.`, "info");
|
|
589
|
+
}
|
|
590
|
+
|
|
554
591
|
function refreshStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): void {
|
|
555
592
|
if (!ctx.hasUI) return;
|
|
556
593
|
ctx.ui.setStatus(STATUS_KEY, renderFooterStatus(ctx, config, state));
|
|
@@ -582,7 +619,7 @@ function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state
|
|
|
582
619
|
|
|
583
620
|
const pct = Math.round((1 - state.stats.last.compressionRatio) * 100);
|
|
584
621
|
return (
|
|
585
|
-
paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()}
|
|
622
|
+
paint("success", "✓") + paint("dim", ` Headroom -${pct}% (${state.stats.last.tokensSaved.toLocaleString()} saved)`)
|
|
586
623
|
);
|
|
587
624
|
}
|
|
588
625
|
|
|
@@ -616,6 +653,7 @@ function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): stri
|
|
|
616
653
|
` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
|
|
617
654
|
` Shutdown: proxy is left running after Pi exits`,
|
|
618
655
|
` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
|
|
656
|
+
` Mode: ${config.mode}`,
|
|
619
657
|
` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
|
|
620
658
|
"",
|
|
621
659
|
"Session stats:",
|
package/extensions/types.ts
CHANGED
|
@@ -58,11 +58,14 @@ export interface CompressResult {
|
|
|
58
58
|
compressed: boolean;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type HeadroomMode = "normal" | "quiet" | "silent";
|
|
62
|
+
|
|
61
63
|
export interface HeadroomConfig {
|
|
62
64
|
enabled: boolean;
|
|
63
65
|
baseUrl: string;
|
|
64
66
|
allowRemote: boolean;
|
|
65
67
|
autoStart: boolean;
|
|
68
|
+
mode: HeadroomMode;
|
|
66
69
|
command: string;
|
|
67
70
|
minContextTokens: number;
|
|
68
71
|
minMessageChars: number;
|
package/package.json
CHANGED