@raquezha/noheadroom 0.2.5 → 0.3.1

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.d.ts CHANGED
@@ -12,6 +12,8 @@ interface HeadroomRuntimeState {
12
12
  lastInputFingerprint: string | null;
13
13
  lastOutputFingerprint: string | null;
14
14
  lastGuardSkipCandidateFingerprint: string | null;
15
+ seenCandidateContentFingerprints?: Set<string>;
16
+ seenCandidateContentOrder?: string[];
15
17
  lastCompressionTime: number;
16
18
  stats: HeadroomStats;
17
19
  }
package/dist/index.js CHANGED
@@ -2,11 +2,16 @@ 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";
11
+ // Bounded FIFO, not config: this is a safety valve for duplicate-compression loops.
12
+ // 512 hashes covers roughly 256 compressed candidates because we store original + compressed text hashes.
13
+ // That is far beyond the reread-loop case while keeping memory bounded for long sessions.
14
+ const MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS = 512;
10
15
  const COMPRESSION_ENTRY_TYPE = "noheadroom.compression";
11
16
  export default function headroomExtension(pi) {
12
17
  registerCompressionRenderer(pi);
@@ -26,15 +31,30 @@ export default function headroomExtension(pi) {
26
31
  });
27
32
  pi.on("context", (event, ctx) => handleContextCompression(runtime, event, ctx));
28
33
  pi.registerCommand("headroom", {
29
- description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats]",
34
+ description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats|mode <normal|quiet|silent>]",
30
35
  getArgumentCompletions(argumentPrefix) {
31
- const prefix = argumentPrefix.trim().toLowerCase();
36
+ const parts = argumentPrefix.trim().toLowerCase().split(/\s+/);
37
+ if (parts[0] === "mode") {
38
+ const modePfx = parts[1] ?? "";
39
+ return MODES.filter((m) => m.startsWith(modePfx)).map((m) => ({
40
+ value: `mode ${m}`,
41
+ label: `mode ${m}`,
42
+ }));
43
+ }
44
+ const prefix = parts[0] ?? "";
32
45
  return SUBCOMMANDS.filter((command) => command.startsWith(prefix)).map((command) => ({
33
46
  value: command,
34
47
  label: command,
35
48
  }));
36
49
  },
37
- handler: async (args, ctx) => handleCommand(runtime, parseSubcommand(args), ctx),
50
+ handler: async (args, ctx) => {
51
+ const parts = args.trim().split(/\s+/);
52
+ const first = (parts[0] ?? "").toLowerCase();
53
+ if (first === "mode") {
54
+ return handleModeChange(runtime, parts[1]?.toLowerCase() ?? "", ctx);
55
+ }
56
+ return handleCommand(runtime, parseSubcommand(first), ctx);
57
+ },
38
58
  });
39
59
  pi.registerCommand("headroom-health", {
40
60
  description: "Check Headroom proxy health",
@@ -57,6 +77,8 @@ function createRuntime(pi) {
57
77
  lastInputFingerprint: null,
58
78
  lastOutputFingerprint: null,
59
79
  lastGuardSkipCandidateFingerprint: null,
80
+ seenCandidateContentFingerprints: new Set(),
81
+ seenCandidateContentOrder: [],
60
82
  lastCompressionTime: 0,
61
83
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
62
84
  };
@@ -211,6 +233,9 @@ async function handleContextCompression(runtime, event, ctx) {
211
233
  if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
212
234
  return undefined;
213
235
  }
236
+ if (allCandidateContentSeen(runtime.state, payload)) {
237
+ return undefined;
238
+ }
214
239
  if (runtime.state.proxyOnline !== true) {
215
240
  void ensureProxyInBackground(runtime, ctx);
216
241
  return undefined;
@@ -241,7 +266,7 @@ async function handleContextCompression(runtime, event, ctx) {
241
266
  runtime.state.lastGuardSkipCandidateFingerprint = candidateFingerprint;
242
267
  recordGuardSkip(runtime.state.stats, applied.reason);
243
268
  emitNotraceTelemetry(runtime);
244
- announceGuardSkip(ctx, applied.reason, result);
269
+ announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
245
270
  runtime.refreshStatus(ctx);
246
271
  return undefined;
247
272
  }
@@ -249,9 +274,10 @@ async function handleContextCompression(runtime, event, ctx) {
249
274
  runtime.state.lastInputFingerprint = inputFingerprint;
250
275
  runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
251
276
  runtime.state.lastGuardSkipCandidateFingerprint = null;
277
+ recordSeenCandidateContent(runtime.state, payload, result.messages);
252
278
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
253
279
  emitNotraceTelemetry(runtime);
254
- announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
280
+ announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
255
281
  runtime.refreshStatus(ctx);
256
282
  return { messages: applied.messages };
257
283
  }
@@ -263,6 +289,38 @@ async function handleContextCompression(runtime, event, ctx) {
263
289
  runtime.state.processing = false;
264
290
  }
265
291
  }
292
+ function allCandidateContentSeen(state, payload) {
293
+ const seen = state.seenCandidateContentFingerprints;
294
+ if (!seen?.size)
295
+ return false;
296
+ const hashes = payload.mappings.filter((mapping) => mapping.applyTo).map((mapping) => stableHash(mapping.originalText));
297
+ return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
298
+ }
299
+ function recordSeenCandidateContent(state, payload, compressedMessages) {
300
+ state.seenCandidateContentFingerprints ??= new Set();
301
+ state.seenCandidateContentOrder ??= [];
302
+ payload.mappings.forEach((mapping, index) => {
303
+ if (!mapping.applyTo)
304
+ return;
305
+ addSeenCandidateHash(state, stableHash(mapping.originalText));
306
+ const compressed = compressedMessages[index];
307
+ if (compressed)
308
+ addSeenCandidateHash(state, stableHash(extractOpenAIText(compressed)));
309
+ });
310
+ }
311
+ function addSeenCandidateHash(state, hash) {
312
+ state.seenCandidateContentFingerprints ??= new Set();
313
+ state.seenCandidateContentOrder ??= [];
314
+ if (state.seenCandidateContentFingerprints.has(hash))
315
+ return;
316
+ state.seenCandidateContentFingerprints.add(hash);
317
+ state.seenCandidateContentOrder.push(hash);
318
+ while (state.seenCandidateContentOrder.length > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
319
+ const oldest = state.seenCandidateContentOrder.shift();
320
+ if (oldest)
321
+ state.seenCandidateContentFingerprints.delete(oldest);
322
+ }
323
+ }
266
324
  function generateCandidateFingerprint(messages, payload) {
267
325
  const units = payload.mappings
268
326
  .filter((mapping) => mapping.applyTo)
@@ -381,7 +439,9 @@ function emitNotraceTelemetry(runtime) {
381
439
  // Telemetry should never break compression behavior.
382
440
  }
383
441
  }
384
- function announceAppliedCompression(pi, ctx, result, appliedMessages) {
442
+ function announceAppliedCompression(pi, ctx, result, appliedMessages, mode = "normal") {
443
+ if (mode === "quiet" || mode === "silent")
444
+ return;
385
445
  const pct = Math.round((1 - result.compressionRatio) * 100);
386
446
  const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
387
447
  const line = `noheadroom: ${summary}`;
@@ -393,7 +453,9 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages) {
393
453
  if (!ctx.hasUI)
394
454
  process.stderr.write(`🗜 ${line}\n`);
395
455
  }
396
- function announceGuardSkip(ctx, reason, result) {
456
+ function announceGuardSkip(ctx, reason, result, mode = "normal") {
457
+ if (mode === "silent")
458
+ return;
397
459
  const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
398
460
  ctx.ui.notify(line, "warning");
399
461
  // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
@@ -462,6 +524,18 @@ async function handleCommand(runtime, command, ctx) {
462
524
  }
463
525
  ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
464
526
  }
527
+ async function handleModeChange(runtime, rawMode, ctx) {
528
+ const VALID_MODES = ["normal", "quiet", "silent"];
529
+ if (!VALID_MODES.includes(rawMode)) {
530
+ ctx.ui.notify(`Unknown mode "${rawMode}". Valid modes: ${VALID_MODES.join(", ")}\nUsage: /headroom mode <normal|quiet|silent>`, "warning");
531
+ return;
532
+ }
533
+ const mode = rawMode;
534
+ runtime.config.mode = mode;
535
+ saveHeadroomSettings({ mode });
536
+ emitNotraceTelemetry(runtime);
537
+ ctx.ui.notify(`Headroom output mode set to "${mode}" and saved to settings.`, "info");
538
+ }
465
539
  function refreshStatus(ctx, config, state) {
466
540
  if (!ctx.hasUI)
467
541
  return;
@@ -515,6 +589,7 @@ function renderStatus(config, state) {
515
589
  ` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
516
590
  ` Shutdown: proxy is left running after Pi exits`,
517
591
  ` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
592
+ ` Mode: ${config.mode}`,
518
593
  ` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
519
594
  "",
520
595
  "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,13 +8,18 @@ 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";
19
+ // Bounded FIFO, not config: this is a safety valve for duplicate-compression loops.
20
+ // 512 hashes covers roughly 256 compressed candidates because we store original + compressed text hashes.
21
+ // That is far beyond the reread-loop case while keeping memory bounded for long sessions.
22
+ const MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS = 512;
18
23
 
19
24
  type Subcommand = (typeof SUBCOMMANDS)[number];
20
25
 
@@ -29,6 +34,8 @@ interface HeadroomRuntimeState {
29
34
  lastInputFingerprint: string | null;
30
35
  lastOutputFingerprint: string | null;
31
36
  lastGuardSkipCandidateFingerprint: string | null;
37
+ seenCandidateContentFingerprints?: Set<string>;
38
+ seenCandidateContentOrder?: string[];
32
39
  lastCompressionTime: number;
33
40
  stats: HeadroomStats;
34
41
  }
@@ -78,15 +85,30 @@ export default function headroomExtension(pi: ExtensionAPI) {
78
85
  pi.on("context", (event, ctx) => handleContextCompression(runtime, event, ctx));
79
86
 
80
87
  pi.registerCommand("headroom", {
81
- description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats]",
88
+ description: "Headroom token compression. Usage: /headroom [on|off|status|health|stats|mode <normal|quiet|silent>]",
82
89
  getArgumentCompletions(argumentPrefix) {
83
- const prefix = argumentPrefix.trim().toLowerCase();
90
+ const parts = argumentPrefix.trim().toLowerCase().split(/\s+/);
91
+ if (parts[0] === "mode") {
92
+ const modePfx = parts[1] ?? "";
93
+ return MODES.filter((m) => m.startsWith(modePfx)).map((m) => ({
94
+ value: `mode ${m}`,
95
+ label: `mode ${m}`,
96
+ }));
97
+ }
98
+ const prefix = parts[0] ?? "";
84
99
  return SUBCOMMANDS.filter((command) => command.startsWith(prefix)).map((command) => ({
85
100
  value: command,
86
101
  label: command,
87
102
  }));
88
103
  },
89
- handler: async (args, ctx) => handleCommand(runtime, parseSubcommand(args), ctx),
104
+ handler: async (args, ctx) => {
105
+ const parts = args.trim().split(/\s+/);
106
+ const first = (parts[0] ?? "").toLowerCase();
107
+ if (first === "mode") {
108
+ return handleModeChange(runtime, parts[1]?.toLowerCase() ?? "", ctx);
109
+ }
110
+ return handleCommand(runtime, parseSubcommand(first), ctx);
111
+ },
90
112
  });
91
113
 
92
114
  pi.registerCommand("headroom-health", {
@@ -111,6 +133,8 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
111
133
  lastInputFingerprint: null,
112
134
  lastOutputFingerprint: null,
113
135
  lastGuardSkipCandidateFingerprint: null,
136
+ seenCandidateContentFingerprints: new Set(),
137
+ seenCandidateContentOrder: [],
114
138
  lastCompressionTime: 0,
115
139
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
116
140
  };
@@ -275,6 +299,9 @@ async function handleContextCompression(
275
299
  if (runtime.state.lastGuardSkipCandidateFingerprint === candidateFingerprint) {
276
300
  return undefined;
277
301
  }
302
+ if (allCandidateContentSeen(runtime.state, payload)) {
303
+ return undefined;
304
+ }
278
305
  if (runtime.state.proxyOnline !== true) {
279
306
  void ensureProxyInBackground(runtime, ctx);
280
307
  return undefined;
@@ -308,7 +335,7 @@ async function handleContextCompression(
308
335
 
309
336
  recordGuardSkip(runtime.state.stats, applied.reason);
310
337
  emitNotraceTelemetry(runtime);
311
- announceGuardSkip(ctx, applied.reason, result);
338
+ announceGuardSkip(ctx, applied.reason, result, runtime.config.mode);
312
339
  runtime.refreshStatus(ctx);
313
340
  return undefined;
314
341
  }
@@ -317,10 +344,11 @@ async function handleContextCompression(
317
344
  runtime.state.lastInputFingerprint = inputFingerprint;
318
345
  runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
319
346
  runtime.state.lastGuardSkipCandidateFingerprint = null;
347
+ recordSeenCandidateContent(runtime.state, payload, result.messages);
320
348
 
321
349
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
322
350
  emitNotraceTelemetry(runtime);
323
- announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
351
+ announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages, runtime.config.mode);
324
352
  runtime.refreshStatus(ctx);
325
353
  return { messages: applied.messages };
326
354
  } catch (error) {
@@ -331,6 +359,40 @@ async function handleContextCompression(
331
359
  }
332
360
  }
333
361
 
362
+ function allCandidateContentSeen(state: HeadroomRuntimeState, payload: CompressionPayload): boolean {
363
+ const seen = state.seenCandidateContentFingerprints;
364
+ if (!seen?.size) return false;
365
+ const hashes = payload.mappings.filter((mapping) => mapping.applyTo).map((mapping) => stableHash(mapping.originalText));
366
+ return hashes.length > 0 && hashes.every((hash) => seen.has(hash));
367
+ }
368
+
369
+ function recordSeenCandidateContent(
370
+ state: HeadroomRuntimeState,
371
+ payload: CompressionPayload,
372
+ compressedMessages: CompressionPayload["messages"],
373
+ ): void {
374
+ state.seenCandidateContentFingerprints ??= new Set();
375
+ state.seenCandidateContentOrder ??= [];
376
+ payload.mappings.forEach((mapping, index) => {
377
+ if (!mapping.applyTo) return;
378
+ addSeenCandidateHash(state, stableHash(mapping.originalText));
379
+ const compressed = compressedMessages[index];
380
+ if (compressed) addSeenCandidateHash(state, stableHash(extractOpenAIText(compressed)));
381
+ });
382
+ }
383
+
384
+ function addSeenCandidateHash(state: HeadroomRuntimeState, hash: string): void {
385
+ state.seenCandidateContentFingerprints ??= new Set();
386
+ state.seenCandidateContentOrder ??= [];
387
+ if (state.seenCandidateContentFingerprints.has(hash)) return;
388
+ state.seenCandidateContentFingerprints.add(hash);
389
+ state.seenCandidateContentOrder.push(hash);
390
+ while (state.seenCandidateContentOrder.length > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
391
+ const oldest = state.seenCandidateContentOrder.shift();
392
+ if (oldest) state.seenCandidateContentFingerprints.delete(oldest);
393
+ }
394
+ }
395
+
334
396
  function generateCandidateFingerprint(messages: AgentMessage[], payload: CompressionPayload): string {
335
397
  const units = payload.mappings
336
398
  .filter((mapping) => mapping.applyTo)
@@ -458,7 +520,9 @@ function announceAppliedCompression(
458
520
  ctx: ExtensionContext,
459
521
  result: CompressResult,
460
522
  appliedMessages: number,
523
+ mode: HeadroomMode = "normal",
461
524
  ): void {
525
+ if (mode === "quiet" || mode === "silent") return;
462
526
  const pct = Math.round((1 - result.compressionRatio) * 100);
463
527
  const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
464
528
  const line = `noheadroom: ${summary}`;
@@ -470,7 +534,8 @@ function announceAppliedCompression(
470
534
  if (!ctx.hasUI) process.stderr.write(`🗜 ${line}\n`);
471
535
  }
472
536
 
473
- function announceGuardSkip(ctx: ExtensionContext, reason: string, result: CompressResult): void {
537
+ function announceGuardSkip(ctx: ExtensionContext, reason: string, result: CompressResult, mode: HeadroomMode = "normal"): void {
538
+ if (mode === "silent") return;
474
539
  const line = `noheadroom: compression skipped by guard (${reason}); Headroom reported ${result.tokensSaved.toLocaleString()} tokens saved but Pi context was left unchanged`;
475
540
  ctx.ui.notify(line, "warning");
476
541
  // In interactive Pi TUI, writing to stderr during the context hook can leave a stale "Working..." row.
@@ -553,6 +618,22 @@ async function handleCommand(runtime: HeadroomRuntime, command: Subcommand, ctx:
553
618
  ctx.ui.notify(renderStatus(runtime.config, runtime.state), "info");
554
619
  }
555
620
 
621
+ async function handleModeChange(runtime: HeadroomRuntime, rawMode: string, ctx: ExtensionContext): Promise<void> {
622
+ const VALID_MODES: HeadroomMode[] = ["normal", "quiet", "silent"];
623
+ if (!VALID_MODES.includes(rawMode as HeadroomMode)) {
624
+ ctx.ui.notify(
625
+ `Unknown mode "${rawMode}". Valid modes: ${VALID_MODES.join(", ")}\nUsage: /headroom mode <normal|quiet|silent>`,
626
+ "warning",
627
+ );
628
+ return;
629
+ }
630
+ const mode = rawMode as HeadroomMode;
631
+ runtime.config.mode = mode;
632
+ saveHeadroomSettings({ mode });
633
+ emitNotraceTelemetry(runtime);
634
+ ctx.ui.notify(`Headroom output mode set to "${mode}" and saved to settings.`, "info");
635
+ }
636
+
556
637
  function refreshStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): void {
557
638
  if (!ctx.hasUI) return;
558
639
  ctx.ui.setStatus(STATUS_KEY, renderFooterStatus(ctx, config, state));
@@ -618,6 +699,7 @@ function renderStatus(config: HeadroomConfig, state: HeadroomRuntimeState): stri
618
699
  ` Auto-start: ${config.autoStart ? `yes (${config.command})` : "no"}`,
619
700
  ` Shutdown: proxy is left running after Pi exits`,
620
701
  ` Remote: ${isRemoteBlocked(config) ? "blocked" : config.allowRemote ? "allowed" : "local-only"}`,
702
+ ` Mode: ${config.mode}`,
621
703
  ` Thresholds: context >= ${config.minContextTokens.toLocaleString()} tokens, toolResult >= ${config.minMessageChars.toLocaleString()} chars`,
622
704
  "",
623
705
  "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,11 +1,12 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
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",
7
7
  "scripts": {
8
8
  "build": "tsc",
9
+ "bench:loop": "node bench-loop-hardening.mjs",
9
10
  "prepare": "npm run build"
10
11
  },
11
12
  "peerDependencies": {