@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 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 may protect/exclude built-in agent tool names such as `read` and `bash`.
128
- // This payload is only for compression alignment; Pi's original tool metadata is preserved
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 prefix = argumentPrefix.trim().toLowerCase();
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) => handleCommand(runtime, parseSubcommand(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;
@@ -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 may protect/exclude built-in agent tool names such as `read` and `bash`.
160
- // This payload is only for compression alignment; Pi's original tool metadata is preserved
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
+ }
@@ -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
+ }
@@ -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 prefix = argumentPrefix.trim().toLowerCase();
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) => handleCommand(runtime, parseSubcommand(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:",
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Personal Headroom compression bridge for Pi with Docker-first defaults and Pi tool-name adaptation",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",