@raquezha/noheadroom 0.0.2 → 0.2.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/dist/bridge.d.ts CHANGED
@@ -1,3 +1,11 @@
1
1
  import type { AgentMessage, ApplyCompressionOptions, ApplyCompressionResult, CompressionMapping, CompressionPayload, OpenAIMessage } from "./types.js";
2
+ type AnyMessage = AgentMessage & {
3
+ role?: string;
4
+ content?: unknown;
5
+ timestamp?: number;
6
+ };
2
7
  export declare function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload;
3
8
  export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], _options: ApplyCompressionOptions): ApplyCompressionResult;
9
+ export declare function convertMessage(message: AnyMessage): OpenAIMessage | undefined;
10
+ export declare function extractOpenAIText(message: OpenAIMessage): string;
11
+ export {};
package/dist/bridge.js CHANGED
@@ -8,8 +8,11 @@ export function buildCompressionPayload(messages, minMessageChars) {
8
8
  if (!converted)
9
9
  continue;
10
10
  const originalText = extractOpenAIText(converted);
11
- const applyTo = source.role === "toolResult" && originalText.length >= minMessageChars ? "toolResult" : null;
12
- if (applyTo)
11
+ // Allow any toolResult to be a candidate for compression if Headroom decides to shrink it.
12
+ // The minMessageChars threshold is primarily to avoid overhead for tiny messages,
13
+ // but we shouldn't block Headroom if it finds savings in slightly smaller ones.
14
+ const applyTo = source.role === "toolResult" ? "toolResult" : null;
15
+ if (applyTo && originalText.length >= minMessageChars)
13
16
  candidateCount++;
14
17
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
15
18
  }
@@ -51,7 +54,7 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
51
54
  }
52
55
  return { ok: true, messages: nextMessages, appliedMessages };
53
56
  }
54
- function convertMessage(message) {
57
+ export function convertMessage(message) {
55
58
  if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role))
56
59
  return undefined;
57
60
  switch (message.role) {
@@ -142,7 +145,7 @@ function validateAlignedMessage(original, compressed) {
142
145
  }
143
146
  return { ok: true };
144
147
  }
145
- function extractOpenAIText(message) {
148
+ export function extractOpenAIText(message) {
146
149
  if (message.role === "assistant")
147
150
  return typeof message.content === "string" ? message.content : "";
148
151
  if (message.role === "tool" || message.role === "system")
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { HeadroomConfig, HeadroomStats } from "./types.js";
1
+ import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { HeadroomHttpClient } from "./client.js";
3
+ import type { AgentMessage, HeadroomConfig, HeadroomStats } from "./types.js";
3
4
  interface HeadroomRuntimeState {
4
5
  enabled: boolean;
5
6
  proxyOnline: boolean | null;
@@ -7,13 +8,32 @@ interface HeadroomRuntimeState {
7
8
  proxyStartAttempted: boolean;
8
9
  remoteWarningShown: boolean;
9
10
  offlineWarningShown: boolean;
11
+ processing: boolean;
12
+ lastInputFingerprint: string | null;
13
+ lastOutputFingerprint: string | null;
14
+ lastCompressionTime: number;
10
15
  stats: HeadroomStats;
11
16
  }
17
+ interface HeadroomRuntime {
18
+ pi: ExtensionAPI;
19
+ config: HeadroomConfig;
20
+ client: HeadroomHttpClient;
21
+ state: HeadroomRuntimeState;
22
+ refreshStatus(ctx: ExtensionContext): void;
23
+ updateHealth(ctx: ExtensionContext): Promise<boolean>;
24
+ ensureProxy(ctx: ExtensionContext): Promise<boolean>;
25
+ }
12
26
  export default function headroomExtension(pi: ExtensionAPI): void;
27
+ declare function handleContextCompression(runtime: HeadroomRuntime, event: ContextEvent, ctx: ExtensionContext): Promise<{
28
+ messages?: AgentMessage[];
29
+ } | undefined>;
30
+ declare function generateFingerprint(messages: AgentMessage[]): string;
13
31
  declare function isAbortOrTimeoutError(error: unknown): boolean;
14
32
  declare function renderFooterStatus(ctx: ExtensionContext, config: HeadroomConfig, state: HeadroomRuntimeState): string;
15
33
  export declare const __test__: {
16
34
  isAbortOrTimeoutError: typeof isAbortOrTimeoutError;
17
35
  renderFooterStatus: typeof renderFooterStatus;
36
+ generateFingerprint: typeof generateFingerprint;
37
+ handleContextCompression: typeof handleContextCompression;
18
38
  };
19
39
  export {};
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from "@earendil-works/pi-tui";
2
- import { applyCompressionResult, buildCompressionPayload } from "./bridge.js";
2
+ import { applyCompressionResult, buildCompressionPayload, convertMessage, extractOpenAIText, } from "./bridge.js";
3
3
  import { HeadroomHttpClient } from "./client.js";
4
4
  import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
5
5
  import { startPersistentHeadroomProxy } from "./proxy-manager.js";
@@ -49,6 +49,10 @@ function createRuntime(pi) {
49
49
  proxyStartAttempted: false,
50
50
  remoteWarningShown: false,
51
51
  offlineWarningShown: false,
52
+ processing: false,
53
+ lastInputFingerprint: null,
54
+ lastOutputFingerprint: null,
55
+ lastCompressionTime: 0,
52
56
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
53
57
  };
54
58
  const runtime = {
@@ -154,7 +158,7 @@ function sleep(ms) {
154
158
  function registerCompressionRenderer(pi) {
155
159
  pi.registerMessageRenderer(COMPRESSION_ENTRY_TYPE, (message, { expanded }, theme) => {
156
160
  const details = message.details;
157
- const content = typeof message.content === "string" ? message.content : "noheadroom compression applied";
161
+ const content = details?.summary ?? (typeof message.content === "string" ? message.content : "noheadroom compression applied");
158
162
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
159
163
  let text = `${theme.fg("success", "🗜 noheadroom")} ${content}`;
160
164
  if (expanded && details) {
@@ -169,6 +173,23 @@ function registerCompressionRenderer(pi) {
169
173
  });
170
174
  }
171
175
  async function handleContextCompression(runtime, event, ctx) {
176
+ if (runtime.state.processing)
177
+ return undefined;
178
+ // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
179
+ const now = Date.now();
180
+ if (now - runtime.state.lastCompressionTime < 3000) {
181
+ return undefined;
182
+ }
183
+ // Content-based guards to prevent infinite recursion
184
+ const inputFingerprint = generateFingerprint(event.messages);
185
+ // 1. If this input matches our previous output, we already compressed it. Stop.
186
+ if (runtime.state.lastOutputFingerprint === inputFingerprint) {
187
+ return undefined;
188
+ }
189
+ // 2. If this input matches our previous input, it didn't change. Stop.
190
+ if (runtime.state.lastInputFingerprint === inputFingerprint) {
191
+ return undefined;
192
+ }
172
193
  if (shouldSkipBeforePayload(runtime, ctx))
173
194
  return undefined;
174
195
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
@@ -178,11 +199,15 @@ async function handleContextCompression(runtime, event, ctx) {
178
199
  void ensureProxyInBackground(runtime, ctx);
179
200
  return undefined;
180
201
  }
202
+ runtime.state.processing = true;
203
+ runtime.state.lastCompressionTime = now;
181
204
  runtime.state.stats.attempts++;
182
205
  try {
183
206
  const result = await runtime.client.compress(payload.messages, ctx.model?.id, ctx.signal);
184
207
  runtime.state.proxyOnline = true;
185
208
  if (!result.compressed || result.tokensSaved <= 0) {
209
+ // Even if no tokens saved, record the input so we don't keep trying
210
+ runtime.state.lastInputFingerprint = inputFingerprint;
186
211
  runtime.refreshStatus(ctx);
187
212
  return undefined;
188
213
  }
@@ -195,6 +220,9 @@ async function handleContextCompression(runtime, event, ctx) {
195
220
  runtime.refreshStatus(ctx);
196
221
  return undefined;
197
222
  }
223
+ // Store fingerprints to break the feedback loop
224
+ runtime.state.lastInputFingerprint = inputFingerprint;
225
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
198
226
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
199
227
  announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
200
228
  runtime.refreshStatus(ctx);
@@ -204,6 +232,18 @@ async function handleContextCompression(runtime, event, ctx) {
204
232
  recordCompressionError(runtime, ctx, error);
205
233
  return undefined;
206
234
  }
235
+ finally {
236
+ runtime.state.processing = false;
237
+ }
238
+ }
239
+ function generateFingerprint(messages) {
240
+ return messages
241
+ .map((m) => {
242
+ const converted = convertMessage(m);
243
+ const len = converted ? extractOpenAIText(converted).length : 0;
244
+ return `${m.role}:${len}`;
245
+ })
246
+ .join(",");
207
247
  }
208
248
  function shouldSkipBeforePayload(runtime, ctx) {
209
249
  if (!runtime.state.enabled)
@@ -235,20 +275,8 @@ function announceAppliedCompression(pi, ctx, result, appliedMessages) {
235
275
  const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
236
276
  const line = `noheadroom: ${summary}`;
237
277
  ctx.ui.notify(line, "info");
238
- pi.sendMessage({
239
- customType: COMPRESSION_ENTRY_TYPE,
240
- content: summary,
241
- display: true,
242
- details: {
243
- tokensBefore: result.tokensBefore,
244
- tokensAfter: result.tokensAfter,
245
- tokensSaved: result.tokensSaved,
246
- compressionRatio: result.compressionRatio,
247
- appliedMessages,
248
- transformsApplied: result.transformsApplied,
249
- timestamp: Date.now(),
250
- },
251
- });
278
+ // Note: we no longer use pi.appendEntry or pi.sendMessage here because
279
+ // modifying the session history inside the context event triggers an infinite loop.
252
280
  // Non-interactive `pi -p` does not always show footer/status UI, so print an explicit proof line.
253
281
  process.stderr.write(`🗜 ${line}\n`);
254
282
  }
@@ -428,4 +456,6 @@ function parseSubcommand(args) {
428
456
  export const __test__ = {
429
457
  isAbortOrTimeoutError,
430
458
  renderFooterStatus,
459
+ generateFingerprint,
460
+ handleContextCompression,
431
461
  };
@@ -28,8 +28,11 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
28
28
  if (!converted) continue;
29
29
 
30
30
  const originalText = extractOpenAIText(converted);
31
- const applyTo = source.role === "toolResult" && originalText.length >= minMessageChars ? "toolResult" : null;
32
- if (applyTo) candidateCount++;
31
+ // Allow any toolResult to be a candidate for compression if Headroom decides to shrink it.
32
+ // The minMessageChars threshold is primarily to avoid overhead for tiny messages,
33
+ // but we shouldn't block Headroom if it finds savings in slightly smaller ones.
34
+ const applyTo = source.role === "toolResult" ? "toolResult" : null;
35
+ if (applyTo && originalText.length >= minMessageChars) candidateCount++;
33
36
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
34
37
  }
35
38
 
@@ -84,7 +87,7 @@ export function applyCompressionResult(
84
87
  return { ok: true, messages: nextMessages, appliedMessages };
85
88
  }
86
89
 
87
- function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
90
+ export function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
88
91
  if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role)) return undefined;
89
92
  switch (message.role) {
90
93
  case "user":
@@ -176,7 +179,7 @@ function validateAlignedMessage(
176
179
  return { ok: true };
177
180
  }
178
181
 
179
- function extractOpenAIText(message: OpenAIMessage): string {
182
+ export function extractOpenAIText(message: OpenAIMessage): string {
180
183
  if (message.role === "assistant") return typeof message.content === "string" ? message.content : "";
181
184
  if (message.role === "tool" || message.role === "system") return message.content;
182
185
  if (typeof message.content === "string") return message.content;
@@ -1,6 +1,11 @@
1
1
  import type { ContextEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, Text } from "@earendil-works/pi-tui";
3
- import { applyCompressionResult, buildCompressionPayload } from "./bridge.js";
3
+ import {
4
+ applyCompressionResult,
5
+ buildCompressionPayload,
6
+ convertMessage,
7
+ extractOpenAIText,
8
+ } from "./bridge.js";
4
9
  import { HeadroomHttpClient } from "./client.js";
5
10
  import { isRemoteBlocked, loadHeadroomConfig } from "./config.js";
6
11
  import { startPersistentHeadroomProxy } from "./proxy-manager.js";
@@ -18,6 +23,10 @@ interface HeadroomRuntimeState {
18
23
  proxyStartAttempted: boolean;
19
24
  remoteWarningShown: boolean;
20
25
  offlineWarningShown: boolean;
26
+ processing: boolean;
27
+ lastInputFingerprint: string | null;
28
+ lastOutputFingerprint: string | null;
29
+ lastCompressionTime: number;
21
30
  stats: HeadroomStats;
22
31
  }
23
32
 
@@ -93,6 +102,10 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
93
102
  proxyStartAttempted: false,
94
103
  remoteWarningShown: false,
95
104
  offlineWarningShown: false,
105
+ processing: false,
106
+ lastInputFingerprint: null,
107
+ lastOutputFingerprint: null,
108
+ lastCompressionTime: 0,
96
109
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
97
110
  };
98
111
 
@@ -200,8 +213,8 @@ function sleep(ms: number): Promise<void> {
200
213
 
201
214
  function registerCompressionRenderer(pi: ExtensionAPI): void {
202
215
  pi.registerMessageRenderer(COMPRESSION_ENTRY_TYPE, (message, { expanded }, theme) => {
203
- const details = message.details as CompressionEntryDetails | undefined;
204
- const content = typeof message.content === "string" ? message.content : "noheadroom compression applied";
216
+ const details = message.details as (CompressionEntryDetails & { summary?: string }) | undefined;
217
+ const content = details?.summary ?? (typeof message.content === "string" ? message.content : "noheadroom compression applied");
205
218
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
206
219
  let text = `${theme.fg("success", "🗜 noheadroom")} ${content}`;
207
220
  if (expanded && details) {
@@ -221,6 +234,27 @@ async function handleContextCompression(
221
234
  event: ContextEvent,
222
235
  ctx: ExtensionContext,
223
236
  ): Promise<{ messages?: AgentMessage[] } | undefined> {
237
+ if (runtime.state.processing) return undefined;
238
+
239
+ // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
240
+ const now = Date.now();
241
+ if (now - runtime.state.lastCompressionTime < 3000) {
242
+ return undefined;
243
+ }
244
+
245
+ // Content-based guards to prevent infinite recursion
246
+ const inputFingerprint = generateFingerprint(event.messages);
247
+
248
+ // 1. If this input matches our previous output, we already compressed it. Stop.
249
+ if (runtime.state.lastOutputFingerprint === inputFingerprint) {
250
+ return undefined;
251
+ }
252
+
253
+ // 2. If this input matches our previous input, it didn't change. Stop.
254
+ if (runtime.state.lastInputFingerprint === inputFingerprint) {
255
+ return undefined;
256
+ }
257
+
224
258
  if (shouldSkipBeforePayload(runtime, ctx)) return undefined;
225
259
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
226
260
  if (payload.candidateCount === 0) return undefined;
@@ -229,11 +263,15 @@ async function handleContextCompression(
229
263
  return undefined;
230
264
  }
231
265
 
266
+ runtime.state.processing = true;
267
+ runtime.state.lastCompressionTime = now;
232
268
  runtime.state.stats.attempts++;
233
269
  try {
234
270
  const result = await runtime.client.compress(payload.messages, ctx.model?.id, ctx.signal);
235
271
  runtime.state.proxyOnline = true;
236
272
  if (!result.compressed || result.tokensSaved <= 0) {
273
+ // Even if no tokens saved, record the input so we don't keep trying
274
+ runtime.state.lastInputFingerprint = inputFingerprint;
237
275
  runtime.refreshStatus(ctx);
238
276
  return undefined;
239
277
  }
@@ -248,6 +286,10 @@ async function handleContextCompression(
248
286
  return undefined;
249
287
  }
250
288
 
289
+ // Store fingerprints to break the feedback loop
290
+ runtime.state.lastInputFingerprint = inputFingerprint;
291
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
292
+
251
293
  recordAppliedCompression(runtime.state.stats, result, applied.appliedMessages);
252
294
  announceAppliedCompression(runtime.pi, ctx, result, applied.appliedMessages);
253
295
  runtime.refreshStatus(ctx);
@@ -255,9 +297,21 @@ async function handleContextCompression(
255
297
  } catch (error) {
256
298
  recordCompressionError(runtime, ctx, error);
257
299
  return undefined;
300
+ } finally {
301
+ runtime.state.processing = false;
258
302
  }
259
303
  }
260
304
 
305
+ function generateFingerprint(messages: AgentMessage[]): string {
306
+ return messages
307
+ .map((m) => {
308
+ const converted = convertMessage(m);
309
+ const len = converted ? extractOpenAIText(converted).length : 0;
310
+ return `${m.role}:${len}`;
311
+ })
312
+ .join(",");
313
+ }
314
+
261
315
  function shouldSkipBeforePayload(runtime: HeadroomRuntime, ctx: ExtensionContext): boolean {
262
316
  if (!runtime.state.enabled) return true;
263
317
  if (isRemoteBlocked(runtime.config)) {
@@ -295,20 +349,8 @@ function announceAppliedCompression(
295
349
  const summary = `compressed ${result.tokensBefore.toLocaleString()} → ${result.tokensAfter.toLocaleString()} tokens (-${pct}%, saved ${result.tokensSaved.toLocaleString()}, messages ${appliedMessages})`;
296
350
  const line = `noheadroom: ${summary}`;
297
351
  ctx.ui.notify(line, "info");
298
- pi.sendMessage({
299
- customType: COMPRESSION_ENTRY_TYPE,
300
- content: summary,
301
- display: true,
302
- details: {
303
- tokensBefore: result.tokensBefore,
304
- tokensAfter: result.tokensAfter,
305
- tokensSaved: result.tokensSaved,
306
- compressionRatio: result.compressionRatio,
307
- appliedMessages,
308
- transformsApplied: result.transformsApplied,
309
- timestamp: Date.now(),
310
- } satisfies CompressionEntryDetails,
311
- });
352
+ // Note: we no longer use pi.appendEntry or pi.sendMessage here because
353
+ // modifying the session history inside the context event triggers an infinite loop.
312
354
  // Non-interactive `pi -p` does not always show footer/status UI, so print an explicit proof line.
313
355
  process.stderr.write(`🗜 ${line}\n`);
314
356
  }
@@ -524,4 +566,6 @@ function parseSubcommand(args: string): Subcommand {
524
566
  export const __test__ = {
525
567
  isAbortOrTimeoutError,
526
568
  renderFooterStatus,
569
+ generateFingerprint,
570
+ handleContextCompression,
527
571
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.0.2",
3
+ "version": "0.2.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",
@@ -34,6 +34,13 @@
34
34
  "access": "public"
35
35
  },
36
36
  "types": "dist/index.d.ts",
37
+ "files": [
38
+ "dist",
39
+ "extensions",
40
+ "README.md",
41
+ "LICENSE",
42
+ "UPSTREAM-README.md"
43
+ ],
37
44
  "pi": {
38
45
  "extensions": [
39
46
  "extensions"
package/CHANGELOG.md DELETED
@@ -1,7 +0,0 @@
1
- # @raquezha/noheadroom
2
-
3
- ## 0.0.2
4
-
5
- ### Patch Changes
6
-
7
- - 0d65b37: Initial release of the Headroom compression bridge for Pi.
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "declaration": true,
7
- "outDir": "./dist",
8
- "rootDir": "extensions",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "forceConsistentCasingInFileNames": true
13
- },
14
- "include": ["extensions/**/*.ts"]
15
- }