@raquezha/noheadroom 0.2.5 → 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 +42 -9
- package/dist/types.d.ts +2 -0
- package/extensions/bridge.ts +32 -3
- package/extensions/config.ts +33 -1
- package/extensions/index.ts +45 -9
- 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",
|
|
@@ -241,7 +257,7 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
241
257
|
runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
|
|
242
258
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
243
259
|
emitNotraceTelemetry(runtime);
|
|
244
|
-
announceGuardSkip(ctx, applied.reason, result);
|
|
260
|
+
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
245
261
|
runtime.refreshStatus(ctx);
|
|
246
262
|
return undefined;
|
|
247
263
|
}
|
|
@@ -251,7 +267,7 @@ async function handleContextCompression(runtime, event, ctx) {
|
|
|
251
267
|
runtime.state.lastGuardSkipCandidateFingerprint = null;
|
|
252
268
|
recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
|
|
253
269
|
emitNotraceTelemetry(runtime);
|
|
254
|
-
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
|
|
270
|
+
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
|
|
255
271
|
runtime.refreshStatus(ctx);
|
|
256
272
|
return { messages: applied.messages };
|
|
257
273
|
}
|
|
@@ -381,7 +397,9 @@ function emitNotraceTelemetry(runtime) {
|
|
|
381
397
|
// Telemetry should never break compression behavior.
|
|
382
398
|
}
|
|
383
399
|
}
|
|
384
|
-
function announceAppliedCompression(pi, ctx, result, appliedMessages) {
|
|
400
|
+
function announceAppliedCompression(pi, ctx, result, appliedMessages, mode = "normal") {
|
|
401
|
+
if (mode === "quiet" || mode === "silent")
|
|
402
|
+
return;
|
|
385
403
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
386
404
|
const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
|
|
387
405
|
const line = `noheadroom: ${summary}`;
|
|
@@ -393,7 +411,9 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages) {
|
|
|
393
411
|
if (!ctx.hasUI)
|
|
394
412
|
process.stderr.write(`🗜 ${line}\n`);
|
|
395
413
|
}
|
|
396
|
-
function announceGuardSkip(ctx, reason, result) {
|
|
414
|
+
function announceGuardSkip(ctx, reason, result, mode = "normal") {
|
|
415
|
+
if (mode === "silent")
|
|
416
|
+
return;
|
|
397
417
|
const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
|
|
398
418
|
ctx.ui.notify(line, "warning");
|
|
399
419
|
// In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
|
|
@@ -462,6 +482,18 @@ async function handleCommand(runtime, command, ctx) {
|
|
|
462
482
|
}
|
|
463
483
|
ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
|
|
464
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
|
+
}
|
|
465
497
|
function refreshStatus(ctx, config, state) {
|
|
466
498
|
if (!ctx.hasUI)
|
|
467
499
|
return;
|
|
@@ -515,6 +547,7 @@ function renderStatus(config, state) {
|
|
|
515
547
|
` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
|
|
516
548
|
` Shutdown: proxy is left running after Pi exits`,
|
|
517
549
|
` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
|
|
550
|
+
` Mode: ${config.mode}`,
|
|
518
551
|
` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
|
|
519
552
|
"",
|
|
520
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", {
|
|
@@ -308,7 +324,7 @@ async function handleContextCompression(
|
|
|
308
324
|
|
|
309
325
|
recordGuardSkip(runtime.state.stats, applied.reason);
|
|
310
326
|
emitNotraceTelemetry(runtime);
|
|
311
|
-
announceGuardSkip(ctx, applied.reason, result);
|
|
327
|
+
announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
|
|
312
328
|
runtime.refreshStatus(ctx);
|
|
313
329
|
return undefined;
|
|
314
330
|
}
|
|
@@ -320,7 +336,7 @@ async function handleContextCompression(
|
|
|
320
336
|
|
|
321
337
|
recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
|
|
322
338
|
emitNotraceTelemetry(runtime);
|
|
323
|
-
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
|
|
339
|
+
announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
|
|
324
340
|
runtime.refreshStatus(ctx);
|
|
325
341
|
return { messages: applied.messages };
|
|
326
342
|
} catch (error) {
|
|
@@ -458,7 +474,9 @@ function announceAppliedCompression(
|
|
|
458
474
|
ctx: ExtensionContext,
|
|
459
475
|
result: CompressResult,
|
|
460
476
|
appliedMessages: number,
|
|
477
|
+
mode: HeadroomMode = "normal",
|
|
461
478
|
): void {
|
|
479
|
+
if (mode === "quiet" || mode === "silent") return;
|
|
462
480
|
const pct = Math.round((1 - result.compressionRatio) * 100);
|
|
463
481
|
const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
|
|
464
482
|
const line = `noheadroom: ${summary}`;
|
|
@@ -470,7 +488,8 @@ function announceAppliedCompression(
|
|
|
470
488
|
if (!ctx.hasUI) process.stderr.write(`🗜 ${line}\n`);
|
|
471
489
|
}
|
|
472
490
|
|
|
473
|
-
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;
|
|
474
493
|
const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
|
|
475
494
|
ctx.ui.notify(line, "warning");
|
|
476
495
|
// In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
|
|
@@ -553,6 +572,22 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
|
|
|
553
572
|
ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
|
|
554
573
|
}
|
|
555
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
|
+
|
|
556
591
|
function refreshStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): void {
|
|
557
592
|
if (!ctx.hasUI) return;
|
|
558
593
|
ctx.ui.setStatus(STATUS_KEY, renderFooterStatus(ctx, config, state));
|
|
@@ -618,6 +653,7 @@ function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): stri
|
|
|
618
653
|
` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
|
|
619
654
|
` Shutdown: proxy is left running after Pi exits`,
|
|
620
655
|
` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
|
|
656
|
+
` Mode: ${config.mode}`,
|
|
621
657
|
` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
|
|
622
658
|
"",
|
|
623
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