@raquezha/noheadroom 0.3.2 → 0.3.3

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
@@ -1,28 +1,21 @@
1
1
  # noheadroom 🗜
2
2
 
3
- > **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroom-ai/headroom).
3
+ > **Reclaim your Pi context window.** A local-first context compression bridge for the Pi Coding Agent, powered by [Headroom](https://github.com/headroomlabs-ai/headroom).
4
4
 
5
5
  `noheadroom` sits between Pi and your LLM, using Headroom to shrink massive tool outputs/results and logs before they become model input. Headroom itself is a general prompt/context compression engine that can transform broader request material. `noheadroom` deliberately applies a stricter Pi policy: user prompts and assistant messages may be sent as context for the compression request, but Pi only accepts mutations to `toolResult` content. User chat, assistant text, tool-call metadata, and tool IDs remain unchanged in real Pi history. Save tokens, keep more history, and prevent context-overflow in long sessions.
6
6
 
7
7
  ## 🚀 Why noheadroom?
8
8
 
9
- Upstream Headroom protects common agent tool names like `read` and `bash` by default. In a standard Pi workflow, this means large file reads often bypass compression entirely.
10
-
11
- `noheadroom` adapts the compression payload to bypass these exclusions while **fully preserving your Pi session metadata**.
12
-
13
- | Setup | Tool Name | Headroom Action | Savings |
14
- |---|---|---|---|
15
- | Vanilla | `read` | `excluded_tool` | 0% |
16
- | **noheadroom** | `read` | `smart_crusher` | **60-90%** |
9
+ Headroom intentionally protects file reads: changing source text can break exact-match edits. `noheadroom` keeps those reads raw and compresses eligible large text-only tool results (for example, repetitive logs). It never changes the stored Pi session transcript.
17
10
 
18
11
  ## ✨ Features
19
12
 
20
13
  - **Headroom Bridge, Pi Policy**: Headroom can optimize broad prompt/context payloads; `noheadroom` intentionally narrows what gets applied back to Pi so only `toolResult` content mutates.
21
- - **Adaptive Payload Sanitization**: renames tool calls during compression to ensure Headroom actually shrinks them.
22
- - **Strict Candidate Isolation**: limits applied mutations strictly to `toolResult` messages, safely ignoring upstream proxy mangling of user/assistant history to prevent false-positive guard skips.
23
- - **Turn-by-Turn Loop Prevention**: caches eligible `toolResult` candidate fingerprints using tool identity, content shape, length, and stable content hashes to block useless proxy retries across new conversational turns while still retrying when actual tool output changes.
14
+ - **Tool Fidelity**: preserves tool names and arguments so Headroom's protected-tool policy remains effective.
15
+ - **Strict Candidate Isolation**: applies only eligible text-only, non-error `toolResult` changes; rejects retrieval markers without a registered retrieval tool.
16
+ - **Request-local Replay**: reuses validated compression for the same tool call and unchanged result on later model requests without re-calling the proxy. A new tool call with the same output remains raw to avoid re-read loops.
24
17
  - **Pi-Native Metadata Preservation**: original tool IDs and names are never modified in your real session.
25
- - **Deep Visibility**: compression results appear in your terminal, the Pi footer, and as persistent entries in your session history.
18
+ - **Visibility**: session-local compression estimates appear in the terminal and Pi footer; proxy `/stats` does not count extension `/v1/compress` requests as inference savings.
26
19
  - **Docker-First Architecture**: designed to work seamlessly with a local containerized backend.
27
20
  - **Local-First Privacy**: by default, context never leaves your machine.
28
21
 
package/dist/bridge.d.ts CHANGED
@@ -5,8 +5,7 @@ type AnyMessage = AgentMessage & {
5
5
  timestamp?: number;
6
6
  };
7
7
  export declare function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload;
8
- export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], _options: ApplyCompressionOptions): ApplyCompressionResult;
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[];
12
11
  export {};
package/dist/bridge.js CHANGED
@@ -1,4 +1,7 @@
1
1
  const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
2
+ // File reads supply exact edit anchors; directives and recovered evidence must stay raw.
3
+ const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
4
+ const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
2
5
  export function buildCompressionPayload(messages, minMessageChars) {
3
6
  const mappings = [];
4
7
  let candidateCount = 0;
@@ -10,13 +13,19 @@ export function buildCompressionPayload(messages, minMessageChars) {
10
13
  const originalText = extractOpenAIText(converted);
11
14
  // Mark candidates for compression.
12
15
  // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
13
- let applyTo = source.role === "toolResult" ? "toolResult" : null;
16
+ const textOnly = typeof source.content === "string" ||
17
+ (Array.isArray(source.content) && source.content.every(isTextContent));
18
+ let applyTo = source.role === "toolResult" &&
19
+ textOnly && originalText.length >= minMessageChars &&
20
+ !("isError" in source && source.isError) &&
21
+ !PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
22
+ ? "toolResult" : null;
14
23
  // Headroom Bypass Rules (Android Hat)
15
24
  // We never want to compress `android layout` JSON dumps or critical adb dumps.
16
25
  if (source.role === "toolResult" && originalText.trim().startsWith("[") && originalText.includes('"resource-id"')) {
17
26
  applyTo = null;
18
27
  }
19
- if (applyTo && originalText.length >= minMessageChars)
28
+ if (applyTo)
20
29
  candidateCount++;
21
30
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
22
31
  }
@@ -26,8 +35,8 @@ export function buildCompressionPayload(messages, minMessageChars) {
26
35
  candidateCount,
27
36
  };
28
37
  }
29
- export function applyCompressionResult(originalMessages, mappings, compressedMessages, _options) {
30
- if (compressedMessages.length !== mappings.length) {
38
+ export function applyCompressionResult(originalMessages, mappings, compressedMessages, options) {
39
+ if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
31
40
  return { ok: false, reason: "message-count-changed" };
32
41
  }
33
42
  const nextMessages = structuredClone(originalMessages);
@@ -40,16 +49,21 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
40
49
  // We only validate and apply changes to explicit candidates.
41
50
  // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
42
51
  // but we simply ignore those changes and keep the original Pi message intact.
43
- if (!mapping.applyTo)
52
+ if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars)
44
53
  continue;
45
54
  const validation = validateAlignedMessage(mapping.message, compressed);
46
55
  if (!validation.ok)
47
56
  return validation;
48
- let nextText = extractOpenAIText(compressed);
57
+ const nextText = extractOpenAIText(compressed);
49
58
  if (nextText === mapping.originalText)
50
59
  continue;
51
- // Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
52
- nextText = naturalizeHeadroomMarkers(nextText);
60
+ // No retrieval executor is registered. Hiding a hash does not recover evidence.
61
+ if (CCR_MARKER.test(nextText))
62
+ return { ok: false, reason: "unrecoverable-ccr-marker" };
63
+ if (!nextText.trim())
64
+ return { ok: false, reason: "empty-compressed-content" };
65
+ if (estimateTokens(nextText) >= estimateTokens(mapping.originalText))
66
+ continue;
53
67
  const target = nextMessages[mapping.sourceIndex];
54
68
  if (!hasContent(target) || !replaceTextContent(target, nextText)) {
55
69
  return { ok: false, reason: "target-content-unreplaceable" };
@@ -142,14 +156,16 @@ function convertToolCall(toolCall) {
142
156
  id: toolCall.id,
143
157
  type: "function",
144
158
  function: {
145
- // Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
146
- // Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
147
- name: "pi_tool_result",
148
- arguments: JSON.stringify({ originalToolName: toolCall.name }),
159
+ // Preserve upstream tool protections as well as routing context.
160
+ name: toolCall.name,
161
+ arguments: JSON.stringify(toolCall.arguments),
149
162
  },
150
163
  };
151
164
  }
152
165
  function validateAlignedMessage(original, compressed) {
166
+ if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
167
+ return { ok: false, reason: "invalid-compressed-message" };
168
+ }
153
169
  if (original.role !== compressed.role) {
154
170
  return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
155
171
  }
@@ -241,24 +257,3 @@ function estimateTokens(text) {
241
257
  // ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
242
258
  return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
243
259
  }
244
- function naturalizeHeadroomMarkers(text) {
245
- // Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
246
- // into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
247
- 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.]");
248
- }
249
- export function injectCompressionAwareness(messages) {
250
- // If a system message already exists at the top, we append the hint to it.
251
- // Otherwise, we inject a new system message at index 0.
252
- const result = [...messages];
253
- 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.";
254
- const first = result[0];
255
- if (first && first.role === "system" && typeof first.content === "string") {
256
- if (!first.content.includes("automatically compressed by Headroom")) {
257
- result[0] = { ...first, content: `${first.content}\n\n${hintText}` };
258
- }
259
- }
260
- else {
261
- result.unshift({ role: "system", content: hintText });
262
- }
263
- return result;
264
- }
package/dist/client.js CHANGED
@@ -8,14 +8,13 @@ export class HeadroomHttpClient {
8
8
  async health(signal) {
9
9
  try {
10
10
  const response = await fetch(`${this.baseUrl}/health`, {
11
+ redirect: "error",
11
12
  signal: buildSignal(this.timeoutMs, signal),
12
13
  });
13
14
  if (!response.ok)
14
15
  return false;
15
16
  const body = await readJsonObject(response);
16
- if (!body)
17
- return true;
18
- return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
17
+ return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
19
18
  }
20
19
  catch {
21
20
  return false;
@@ -24,12 +23,16 @@ export class HeadroomHttpClient {
24
23
  async probe(signal) {
25
24
  try {
26
25
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
26
+ redirect: "error",
27
27
  method: "POST",
28
28
  headers: { "Content-Type": "application/json" },
29
29
  body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
30
30
  signal: buildSignal(5_000, signal),
31
31
  });
32
- return response.ok;
32
+ if (!response.ok)
33
+ return false;
34
+ validateCompressionResponse(await response.json());
35
+ return true;
33
36
  }
34
37
  catch {
35
38
  return false;
@@ -37,6 +40,7 @@ export class HeadroomHttpClient {
37
40
  }
38
41
  async stats(signal) {
39
42
  const response = await fetch(`${this.baseUrl}/stats`, {
43
+ redirect: "error",
40
44
  signal: buildSignal(this.timeoutMs, signal),
41
45
  });
42
46
  if (!response.ok) {
@@ -46,19 +50,25 @@ export class HeadroomHttpClient {
46
50
  }
47
51
  async compress(messages, model, signal) {
48
52
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
53
+ redirect: "error",
49
54
  method: "POST",
50
55
  headers: {
51
56
  "Content-Type": "application/json",
52
57
  "X-Headroom-Stack": "pi-extension",
53
58
  },
54
- body: JSON.stringify({ messages, model: model || "gpt-4o" }),
59
+ body: JSON.stringify({
60
+ messages,
61
+ model: model || "gpt-4o",
62
+ // 0.39.1's default is marker-free and inherits the backend's --lossless policy.
63
+ // lossy_inline would override that policy and enable lossy SmartCrusher.
64
+ }),
55
65
  signal: buildSignal(this.timeoutMs, signal),
56
66
  });
57
67
  if (!response.ok) {
58
68
  const message = await readErrorMessage(response);
59
69
  throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
60
70
  }
61
- const payload = (await response.json());
71
+ const payload = validateCompressionResponse(await response.json());
62
72
  return {
63
73
  messages: payload.messages,
64
74
  tokensBefore: payload.tokens_before,
@@ -71,6 +81,17 @@ export class HeadroomHttpClient {
71
81
  };
72
82
  }
73
83
  }
84
+ function validateCompressionResponse(value) {
85
+ const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
86
+ const stringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === "string");
87
+ if (!isRecord(value) || !Array.isArray(value.messages) ||
88
+ !metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
89
+ (value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
90
+ (value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || value.ccr_hashes.length > 0))) {
91
+ throw new Error("Invalid or retrieval-dependent Headroom compression response");
92
+ }
93
+ return value;
94
+ }
74
95
  async function readJsonObject(response) {
75
96
  try {
76
97
  const body = (await response.json());
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
5
- const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
5
+ const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
6
6
  const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
7
7
  const DEFAULT_TIMEOUT_MS = 30_000;
8
8
  export const HEADROOM_SETTINGS_DIR = path.join(os.homedir(), ".pi", "agent", "headroom");
@@ -48,7 +48,8 @@ export function loadHeadroomConfig(env = process.env, settings = env === process
48
48
  export function isLocalHeadroomUrl(rawUrl) {
49
49
  try {
50
50
  const url = new URL(rawUrl);
51
- return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
51
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
52
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
52
53
  }
53
54
  catch {
54
55
  return false;
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ interface HeadroomRuntimeState {
14
14
  lastGuardSkipCandidateFingerprint: string | null;
15
15
  seenCandidateContentFingerprints?: Set<string>;
16
16
  seenCandidateContentOrder?: string[];
17
+ compressedCandidates?: Map<string, string>;
17
18
  lastCompressionTime: number;
18
19
  stats: HeadroomStats;
19
20
  }
package/dist/index.js CHANGED
@@ -17,6 +17,14 @@ export default function headroomExtension(pi) {
17
17
  registerCompressionRenderer(pi);
18
18
  const runtime = createRuntime(pi);
19
19
  pi.on("session_start", (_event, ctx) => {
20
+ // A context transform is request-local; cached substitutions must not cross sessions.
21
+ runtime.state.compressedCandidates = new Map();
22
+ runtime.state.seenCandidateContentFingerprints = new Set();
23
+ runtime.state.seenCandidateContentOrder = [];
24
+ runtime.state.lastInputFingerprint = null;
25
+ runtime.state.lastOutputFingerprint = null;
26
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
27
+ runtime.state.lastCompressionTime = 0;
20
28
  if (isRemoteBlocked(runtime.config)) {
21
29
  runtime.refreshStatus(ctx);
22
30
  emitNotraceTelemetry(runtime);
@@ -79,6 +87,7 @@ function createRuntime(pi) {
79
87
  lastGuardSkipCandidateFingerprint: null,
80
88
  seenCandidateContentFingerprints: new Set(),
81
89
  seenCandidateContentOrder: [],
90
+ compressedCandidates: new Map(),
82
91
  lastCompressionTime: 0,
83
92
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
84
93
  };
@@ -207,33 +216,29 @@ function registerCompressionRenderer(pi) {
207
216
  async function handleContextCompression(runtime, event, ctx) {
208
217
  if (runtime.state.processing)
209
218
  return undefined;
219
+ let replayed;
210
220
  const bypassUncompressed = () => {
211
221
  if (runtime.state.stats.last) {
212
222
  runtime.state.stats.last = undefined;
213
223
  runtime.refreshStatus(ctx);
214
224
  }
215
- return undefined;
225
+ return replayed ? { messages: replayed } : undefined;
216
226
  };
217
- // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
218
- const now = Date.now();
219
- if (now - runtime.state.lastCompressionTime < 3000) {
220
- return bypassUncompressed();
221
- }
222
- // Content-based guards to prevent infinite recursion
223
- const inputFingerprint = generateFingerprint(event.messages);
224
- // 1. If this input matches our previous output, we already compressed it. Stop.
225
- if (runtime.state.lastOutputFingerprint === inputFingerprint) {
226
- return undefined;
227
- }
228
- // 2. If this input matches our previous input, it didn't change. Stop.
229
- if (runtime.state.lastInputFingerprint === inputFingerprint) {
230
- return bypassUncompressed();
231
- }
232
227
  if (shouldSkipBeforePayload(runtime, ctx))
233
228
  return bypassUncompressed();
234
229
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
230
+ replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
235
231
  if (payload.candidateCount === 0)
236
232
  return bypassUncompressed();
233
+ // Pi's context transform is request-local; reuse validated text on later turns.
234
+ const now = Date.now();
235
+ if (now - runtime.state.lastCompressionTime < 3000)
236
+ return bypassUncompressed();
237
+ const inputFingerprint = generateFingerprint(event.messages);
238
+ if (runtime.state.lastOutputFingerprint === inputFingerprint)
239
+ return undefined;
240
+ if (runtime.state.lastInputFingerprint === inputFingerprint)
241
+ return bypassUncompressed();
237
242
  // 3. If eligible candidates haven't changed since the last skip/no-savings result,
238
243
  // don't spend another proxy call just because surrounding conversation changed.
239
244
  const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
@@ -276,9 +281,17 @@ async function handleContextCompression(runtime, event, ctx) {
276
281
  }
277
282
  // Store fingerprints to break the feedback loop
278
283
  runtime.state.lastInputFingerprint = inputFingerprint;
279
- runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
280
284
  runtime.state.lastGuardSkipCandidateFingerprint = null;
281
285
  recordSeenCandidateContent(runtime.state, payload, applied.messages);
286
+ if (replayed) {
287
+ for (const mapping of payload.mappings) {
288
+ if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
289
+ applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
290
+ }
291
+ }
292
+ }
293
+ recordCachedCandidates(runtime.state, payload, applied.messages);
294
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
282
295
  const appliedResult = {
283
296
  ...result,
284
297
  tokensBefore: applied.appliedTokensBefore,
@@ -300,6 +313,43 @@ async function handleContextCompression(runtime, event, ctx) {
300
313
  runtime.state.processing = false;
301
314
  }
302
315
  }
316
+ function candidateKey(message, text) {
317
+ const source = message;
318
+ return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
319
+ }
320
+ function replayCachedCandidates(state, messages, payload, minMessageChars) {
321
+ if (!state.compressedCandidates?.size)
322
+ return undefined;
323
+ const mappings = payload.mappings.map((mapping) => ({
324
+ ...mapping,
325
+ applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
326
+ ? mapping.applyTo : null,
327
+ }));
328
+ const compressed = mappings.map((mapping) => {
329
+ const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
330
+ return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
331
+ });
332
+ const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
333
+ return applied.ok ? applied.messages : undefined;
334
+ }
335
+ function recordCachedCandidates(state, payload, messages) {
336
+ state.compressedCandidates ??= new Map();
337
+ for (const mapping of payload.mappings) {
338
+ if (!mapping.applyTo)
339
+ continue;
340
+ const applied = convertMessage(messages[mapping.sourceIndex]);
341
+ if (!applied || applied.role !== "tool")
342
+ continue;
343
+ const text = extractOpenAIText(applied);
344
+ if (text === mapping.originalText)
345
+ continue;
346
+ const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
347
+ state.compressedCandidates.set(key, text);
348
+ if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
349
+ state.compressedCandidates.delete(state.compressedCandidates.keys().next().value);
350
+ }
351
+ }
352
+ }
303
353
  function allCandidateContentSeen(state, payload) {
304
354
  const seen = state.seenCandidateContentFingerprints;
305
355
  if (!seen?.size)
@@ -13,27 +13,33 @@ export async function startPersistentHeadroomProxy(config) {
13
13
  env: {
14
14
  ...process.env,
15
15
  HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
16
+ HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
16
17
  },
17
18
  });
18
- child.unref();
19
- return { ok: true };
19
+ return await new Promise((resolve) => {
20
+ child.once("error", (error) => resolve({ ok: false, reason: error.message }));
21
+ child.once("spawn", () => {
22
+ child.unref();
23
+ resolve({ ok: true });
24
+ });
25
+ });
20
26
  }
21
27
  catch (error) {
22
28
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
23
29
  }
24
30
  }
25
31
  export function buildProxyArgs(endpoint) {
26
- return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "token", "--no-cache"];
32
+ return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
27
33
  }
28
34
  export function parseLocalEndpoint(baseUrl) {
29
35
  try {
30
36
  const url = new URL(baseUrl);
31
- if (!["http:", "https:"].includes(url.protocol))
37
+ if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
32
38
  return undefined;
33
39
  if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname))
34
40
  return undefined;
35
41
  const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
36
- const port = url.port || "8787";
42
+ const port = url.port || "80";
37
43
  return { host, port };
38
44
  }
39
45
  catch {
@@ -17,6 +17,9 @@ interface MessageWithContent {
17
17
  }
18
18
 
19
19
  const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
20
+ // File reads supply exact edit anchors; directives and recovered evidence must stay raw.
21
+ const PROTECTED_TOOLS = new Set(["read", "read_file", "view", "edit", "write", "skill", "headroom_retrieve"]);
22
+ const CCR_MARKER = /Retrieve\s+(?:more|original):\s*hash=|<<ccr:/i;
20
23
 
21
24
  export function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload {
22
25
  const mappings: CompressionMapping[] = [];
@@ -30,7 +33,13 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
30
33
  const originalText = extractOpenAIText(converted);
31
34
  // Mark candidates for compression.
32
35
  // ONLY toolResults are candidates, preserving original Pi conversation fidelity.
33
- let applyTo: "toolResult" | null = source.role === "toolResult" ? "toolResult" : null;
36
+ const textOnly = typeof source.content === "string" ||
37
+ (Array.isArray(source.content) && source.content.every(isTextContent));
38
+ let applyTo: "toolResult" | null = source.role === "toolResult" &&
39
+ textOnly && originalText.length >= minMessageChars &&
40
+ !("isError" in source && source.isError) &&
41
+ !PROTECTED_TOOLS.has((readStringProperty(source, "toolName") ?? "").toLowerCase())
42
+ ? "toolResult" : null;
34
43
 
35
44
  // Headroom Bypass Rules (Android Hat)
36
45
  // We never want to compress `android layout` JSON dumps or critical adb dumps.
@@ -38,7 +47,7 @@ export function buildCompressionPayload(messages: AgentMessage[], minMessageChar
38
47
  applyTo = null;
39
48
  }
40
49
 
41
- if (applyTo && originalText.length >= minMessageChars) candidateCount++;
50
+ if (applyTo) candidateCount++;
42
51
  mappings.push({ sourceIndex, message: converted, applyTo, originalText });
43
52
  }
44
53
 
@@ -53,9 +62,9 @@ export function applyCompressionResult(
53
62
  originalMessages: AgentMessage[],
54
63
  mappings: CompressionMapping[],
55
64
  compressedMessages: OpenAIMessage[],
56
- _options: ApplyCompressionOptions,
65
+ options: ApplyCompressionOptions,
57
66
  ): ApplyCompressionResult {
58
- if (compressedMessages.length !== mappings.length) {
67
+ if (!Array.isArray(compressedMessages) || compressedMessages.length !== mappings.length) {
59
68
  return { ok: false, reason: "message-count-changed" };
60
69
  }
61
70
 
@@ -71,16 +80,17 @@ export function applyCompressionResult(
71
80
  // We only validate and apply changes to explicit candidates.
72
81
  // Headroom is allowed to mangle non-candidates (like assistant history) in its output,
73
82
  // but we simply ignore those changes and keep the original Pi message intact.
74
- if (!mapping.applyTo) continue;
83
+ if (!mapping.applyTo || mapping.originalText.length < options.minMessageChars) continue;
75
84
 
76
85
  const validation = validateAlignedMessage(mapping.message, compressed);
77
86
  if (!validation.ok) return validation;
78
87
 
79
- let nextText = extractOpenAIText(compressed);
88
+ const nextText = extractOpenAIText(compressed);
80
89
  if (nextText === mapping.originalText) continue;
81
-
82
- // Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
83
- nextText = naturalizeHeadroomMarkers(nextText);
90
+ // No retrieval executor is registered. Hiding a hash does not recover evidence.
91
+ if (CCR_MARKER.test(nextText)) return { ok: false, reason: "unrecoverable-ccr-marker" };
92
+ if (!nextText.trim()) return { ok: false, reason: "empty-compressed-content" };
93
+ if (estimateTokens(nextText) >= estimateTokens(mapping.originalText)) continue;
84
94
 
85
95
  const target = nextMessages[mapping.sourceIndex] as AnyMessage;
86
96
  if (!hasContent(target) || !replaceTextContent(target, nextText)) {
@@ -175,10 +185,9 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
175
185
  id: toolCall.id,
176
186
  type: "function",
177
187
  function: {
178
- // Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
179
- // Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
180
- name: "pi_tool_result",
181
- arguments: JSON.stringify({ originalToolName: toolCall.name }),
188
+ // Preserve upstream tool protections as well as routing context.
189
+ name: toolCall.name,
190
+ arguments: JSON.stringify(toolCall.arguments),
182
191
  },
183
192
  };
184
193
  }
@@ -187,6 +196,9 @@ function validateAlignedMessage(
187
196
  original: OpenAIMessage,
188
197
  compressed: OpenAIMessage,
189
198
  ): { ok: true } | { ok: false; reason: string } {
199
+ if (!isRecord(compressed) || (original.role === "tool" && typeof compressed.content !== "string")) {
200
+ return { ok: false, reason: "invalid-compressed-message" };
201
+ }
190
202
  if (original.role !== compressed.role) {
191
203
  return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
192
204
  }
@@ -287,30 +299,3 @@ function estimateTokens(text: string): number {
287
299
  // ponytail: cheap local estimate; exact tokenizer would add a heavy dependency for a footer number.
288
300
  return text.length === 0 ? 0 : Math.max(1, Math.ceil(text.length / 4));
289
301
  }
290
-
291
- function naturalizeHeadroomMarkers(text: string): string {
292
- // Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
293
- // into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
294
- return text.replace(
295
- /\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi,
296
- "[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]"
297
- );
298
- }
299
-
300
- export function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[] {
301
- // If a system message already exists at the top, we append the hint to it.
302
- // Otherwise, we inject a new system message at index 0.
303
- const result = [...messages];
304
- 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.";
305
-
306
- const first = result[0] as unknown as Record<string, unknown>;
307
- if (first && first.role === "system" && typeof first.content === "string") {
308
- if (!first.content.includes("automatically compressed by Headroom")) {
309
- result[0] = { ...first, content: `${first.content}\n\n${hintText}` } as unknown as AgentMessage;
310
- }
311
- } else {
312
- result.unshift({ role: "system", content: hintText } as unknown as AgentMessage);
313
- }
314
-
315
- return result;
316
- }
@@ -27,12 +27,12 @@ export class HeadroomHttpClient {
27
27
  async health(signal?: AbortSignal): Promise<boolean> {
28
28
  try {
29
29
  const response = await fetch(`${this.baseUrl}/health`, {
30
+ redirect: "error",
30
31
  signal: buildSignal(this.timeoutMs, signal),
31
32
  });
32
33
  if (!response.ok) return false;
33
34
  const body = await readJsonObject(response);
34
- if (!body) return true;
35
- return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
35
+ return Boolean(body && body.ready !== false && (body.status === "healthy" || body.status === "ok"));
36
36
  } catch {
37
37
  return false;
38
38
  }
@@ -41,12 +41,15 @@ export class HeadroomHttpClient {
41
41
  async probe(signal?: AbortSignal): Promise<boolean> {
42
42
  try {
43
43
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
44
+ redirect: "error",
44
45
  method: "POST",
45
46
  headers: { "Content-Type": "application/json" },
46
47
  body: JSON.stringify({ messages: [{ role: "user", content: "ping" }], model: "gpt-4o" }),
47
48
  signal: buildSignal(5_000, signal),
48
49
  });
49
- return response.ok;
50
+ if (!response.ok) return false;
51
+ validateCompressionResponse(await response.json());
52
+ return true;
50
53
  } catch {
51
54
  return false;
52
55
  }
@@ -54,6 +57,7 @@ export class HeadroomHttpClient {
54
57
 
55
58
  async stats(signal?: AbortSignal): Promise<unknown> {
56
59
  const response = await fetch(`${this.baseUrl}/stats`, {
60
+ redirect: "error",
57
61
  signal: buildSignal(this.timeoutMs, signal),
58
62
  });
59
63
  if (!response.ok) {
@@ -64,12 +68,18 @@ export class HeadroomHttpClient {
64
68
 
65
69
  async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
66
70
  const response = await fetch(`${this.baseUrl}/v1/compress`, {
71
+ redirect: "error",
67
72
  method: "POST",
68
73
  headers: {
69
74
  "Content-Type": "application/json",
70
75
  "X-Headroom-Stack": "pi-extension",
71
76
  },
72
- body: JSON.stringify({ messages, model: model || "gpt-4o" }),
77
+ body: JSON.stringify({
78
+ messages,
79
+ model: model || "gpt-4o",
80
+ // 0.39.1's default is marker-free and inherits the backend's --lossless policy.
81
+ // lossy_inline would override that policy and enable lossy SmartCrusher.
82
+ }),
73
83
  signal: buildSignal(this.timeoutMs, signal),
74
84
  });
75
85
 
@@ -78,7 +88,7 @@ export class HeadroomHttpClient {
78
88
  throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
79
89
  }
80
90
 
81
- const payload = (await response.json()) as ProxyCompressResponse;
91
+ const payload = validateCompressionResponse(await response.json());
82
92
  return {
83
93
  messages: payload.messages,
84
94
  tokensBefore: payload.tokens_before,
@@ -92,6 +102,18 @@ export class HeadroomHttpClient {
92
102
  }
93
103
  }
94
104
 
105
+ function validateCompressionResponse(value: unknown): ProxyCompressResponse {
106
+ const metrics = ["tokens_before", "tokens_after", "tokens_saved", "compression_ratio"];
107
+ const stringArray = (v: unknown) => Array.isArray(v) && v.every((item) => typeof item === "string");
108
+ if (!isRecord(value) || !Array.isArray(value.messages) ||
109
+ !metrics.every((key) => typeof value[key] === "number" && Number.isFinite(value[key]) && value[key] >= 0) ||
110
+ (value.transforms_applied !== undefined && !stringArray(value.transforms_applied)) ||
111
+ (value.ccr_hashes !== undefined && (!stringArray(value.ccr_hashes) || (value.ccr_hashes as string[]).length > 0))) {
112
+ throw new Error("Invalid or retrieval-dependent Headroom compression response");
113
+ }
114
+ return value as unknown as ProxyCompressResponse;
115
+ }
116
+
95
117
  async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
96
118
  try {
97
119
  const body = (await response.json()) as unknown;
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import type { HeadroomConfig, HeadroomMode } from "./types.js";
5
5
 
6
6
  const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
7
- const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
7
+ const DEFAULT_MIN_CONTEXT_TOKENS = 10_000;
8
8
  const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
9
9
  const DEFAULT_TIMEOUT_MS = 30_000;
10
10
 
@@ -84,7 +84,8 @@ export function loadHeadroomConfig(
84
84
  export function isLocalHeadroomUrl(rawUrl: string): boolean {
85
85
  try {
86
86
  const url = new URL(rawUrl);
87
- return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
87
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password &&
88
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
88
89
  } catch {
89
90
  return false;
90
91
  }
@@ -36,6 +36,7 @@ interface HeadroomRuntimeState {
36
36
  lastGuardSkipCandidateFingerprint: string | null;
37
37
  seenCandidateContentFingerprints?: Set<string>;
38
38
  seenCandidateContentOrder?: string[];
39
+ compressedCandidates?: Map<string, string>;
39
40
  lastCompressionTime: number;
40
41
  stats: HeadroomStats;
41
42
  }
@@ -67,6 +68,14 @@ export default function headroomExtension(pi: ExtensionAPI) {
67
68
  const runtime = createRuntime(pi);
68
69
 
69
70
  pi.on("session_start", (_event, ctx) => {
71
+ // A context transform is request-local; cached substitutions must not cross sessions.
72
+ runtime.state.compressedCandidates = new Map();
73
+ runtime.state.seenCandidateContentFingerprints = new Set();
74
+ runtime.state.seenCandidateContentOrder = [];
75
+ runtime.state.lastInputFingerprint = null;
76
+ runtime.state.lastOutputFingerprint = null;
77
+ runtime.state.lastGuardSkipCandidateFingerprint = null;
78
+ runtime.state.lastCompressionTime = 0;
70
79
  if (isRemoteBlocked(runtime.config)) {
71
80
  runtime.refreshStatus(ctx);
72
81
  emitNotraceTelemetry(runtime);
@@ -135,6 +144,7 @@ function createRuntime(pi: ExtensionAPI): HeadroomRuntime {
135
144
  lastGuardSkipCandidateFingerprint: null,
136
145
  seenCandidateContentFingerprints: new Set(),
137
146
  seenCandidateContentOrder: [],
147
+ compressedCandidates: new Map(),
138
148
  lastCompressionTime: 0,
139
149
  stats: { attempts: 0, applied: 0, guardSkips: 0, tokensSaved: 0 },
140
150
  };
@@ -270,37 +280,27 @@ async function handleContextCompression(
270
280
  ): Promise<{ messages?: AgentMessage[] } | undefined> {
271
281
  if (runtime.state.processing) return undefined;
272
282
 
283
+ let replayed: AgentMessage[] | undefined;
273
284
  const bypassUncompressed = () => {
274
285
  if (runtime.state.stats.last) {
275
286
  runtime.state.stats.last = undefined;
276
287
  runtime.refreshStatus(ctx);
277
288
  }
278
- return undefined;
289
+ return replayed ? { messages: replayed } : undefined;
279
290
  };
280
291
 
281
- // Throttle: max 1 compression attempt per 3 seconds to kill infinite loops
282
- const now = Date.now();
283
- if (now - runtime.state.lastCompressionTime < 3000) {
284
- return bypassUncompressed();
285
- }
286
-
287
- // Content-based guards to prevent infinite recursion
288
- const inputFingerprint = generateFingerprint(event.messages);
289
-
290
- // 1. If this input matches our previous output, we already compressed it. Stop.
291
- if (runtime.state.lastOutputFingerprint === inputFingerprint) {
292
- return undefined;
293
- }
294
-
295
- // 2. If this input matches our previous input, it didn't change. Stop.
296
- if (runtime.state.lastInputFingerprint === inputFingerprint) {
297
- return bypassUncompressed();
298
- }
299
-
300
292
  if (shouldSkipBeforePayload(runtime, ctx)) return bypassUncompressed();
301
293
  const payload = buildCompressionPayload(event.messages, runtime.config.minMessageChars);
294
+ replayed = replayCachedCandidates(runtime.state, event.messages, payload, runtime.config.minMessageChars);
302
295
  if (payload.candidateCount === 0) return bypassUncompressed();
303
296
 
297
+ // Pi's context transform is request-local; reuse validated text on later turns.
298
+ const now = Date.now();
299
+ if (now - runtime.state.lastCompressionTime < 3000) return bypassUncompressed();
300
+ const inputFingerprint = generateFingerprint(event.messages);
301
+ if (runtime.state.lastOutputFingerprint === inputFingerprint) return undefined;
302
+ if (runtime.state.lastInputFingerprint === inputFingerprint) return bypassUncompressed();
303
+
304
304
  // 3. If eligible candidates haven't changed since the last skip/no-savings result,
305
305
  // don't spend another proxy call just because surrounding conversation changed.
306
306
  const candidateFingerprint = generateCandidateFingerprint(event.messages, payload);
@@ -347,9 +347,17 @@ async function handleContextCompression(
347
347
 
348
348
  // Store fingerprints to break the feedback loop
349
349
  runtime.state.lastInputFingerprint = inputFingerprint;
350
- runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
351
350
  runtime.state.lastGuardSkipCandidateFingerprint = null;
352
351
  recordSeenCandidateContent(runtime.state, payload, applied.messages);
352
+ if (replayed) {
353
+ for (const mapping of payload.mappings) {
354
+ if (runtime.state.compressedCandidates?.has(candidateKey(event.messages[mapping.sourceIndex], mapping.originalText))) {
355
+ applied.messages[mapping.sourceIndex] = replayed[mapping.sourceIndex];
356
+ }
357
+ }
358
+ }
359
+ recordCachedCandidates(runtime.state, payload, applied.messages);
360
+ runtime.state.lastOutputFingerprint = generateFingerprint(applied.messages);
353
361
  const appliedResult = {
354
362
  ...result,
355
363
  tokensBefore: applied.appliedTokensBefore,
@@ -371,6 +379,44 @@ async function handleContextCompression(
371
379
  }
372
380
  }
373
381
 
382
+ function candidateKey(message: AgentMessage, text: string): string {
383
+ const source = message as AgentMessage & { toolCallId?: string; toolName?: string };
384
+ return stableHash(JSON.stringify([source.toolCallId, source.toolName, stableHash(text)]));
385
+ }
386
+
387
+ function replayCachedCandidates(
388
+ state: HeadroomRuntimeState, messages: AgentMessage[], payload: CompressionPayload, minMessageChars: number,
389
+ ): AgentMessage[] | undefined {
390
+ if (!state.compressedCandidates?.size) return undefined;
391
+ const mappings = payload.mappings.map((mapping) => ({
392
+ ...mapping,
393
+ applyTo: mapping.applyTo && state.compressedCandidates?.has(candidateKey(messages[mapping.sourceIndex], mapping.originalText))
394
+ ? mapping.applyTo : null,
395
+ }));
396
+ const compressed = mappings.map((mapping) => {
397
+ const text = mapping.applyTo && state.compressedCandidates?.get(candidateKey(messages[mapping.sourceIndex], mapping.originalText));
398
+ return text && mapping.message.role === "tool" ? { ...mapping.message, content: text } : mapping.message;
399
+ });
400
+ const applied = applyCompressionResult(messages, mappings, compressed, { minMessageChars });
401
+ return applied.ok ? applied.messages : undefined;
402
+ }
403
+
404
+ function recordCachedCandidates(state: HeadroomRuntimeState, payload: CompressionPayload, messages: AgentMessage[]): void {
405
+ state.compressedCandidates ??= new Map();
406
+ for (const mapping of payload.mappings) {
407
+ if (!mapping.applyTo) continue;
408
+ const applied = convertMessage(messages[mapping.sourceIndex]);
409
+ if (!applied || applied.role !== "tool") continue;
410
+ const text = extractOpenAIText(applied);
411
+ if (text === mapping.originalText) continue;
412
+ const key = candidateKey(messages[mapping.sourceIndex], mapping.originalText);
413
+ state.compressedCandidates.set(key, text);
414
+ if (state.compressedCandidates.size > MAX_SEEN_CANDIDATE_CONTENT_FINGERPRINTS) {
415
+ state.compressedCandidates.delete(state.compressedCandidates.keys().next().value!);
416
+ }
417
+ }
418
+ }
419
+
374
420
  function allCandidateContentSeen(state: HeadroomRuntimeState, payload: CompressionPayload): boolean {
375
421
  const seen = state.seenCandidateContentFingerprints;
376
422
  if (!seen?.size) return false;
@@ -22,26 +22,32 @@ export async function startPersistentHeadroomProxy(
22
22
  env: {
23
23
  ...process.env,
24
24
  HEADROOM_TELEMETRY: process.env.HEADROOM_TELEMETRY || "off",
25
+ HEADROOM_BEACON: process.env.HEADROOM_BEACON || "off",
25
26
  },
26
27
  });
27
- child.unref();
28
- return { ok: true };
28
+ return await new Promise((resolve) => {
29
+ child.once("error", (error) => resolve({ ok: false, reason: error.message }));
30
+ child.once("spawn", () => {
31
+ child.unref();
32
+ resolve({ ok: true });
33
+ });
34
+ });
29
35
  } catch (error) {
30
36
  return { ok: false, reason: error instanceof Error ? error.message : String(error) };
31
37
  }
32
38
  }
33
39
 
34
40
  export function buildProxyArgs(endpoint: ProxyEndpoint): string[] {
35
- return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "token", "--no-cache"];
41
+ return ["proxy", "--host", endpoint.host, "--port", endpoint.port, "--mode", "cache", "--lossless", "--no-cache"];
36
42
  }
37
43
 
38
44
  export function parseLocalEndpoint(baseUrl: string): ProxyEndpoint | undefined {
39
45
  try {
40
46
  const url = new URL(baseUrl);
41
- if (!["http:", "https:"].includes(url.protocol)) return undefined;
47
+ if (url.protocol !== "http:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) return undefined;
42
48
  if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname)) return undefined;
43
49
  const host = url.hostname === "localhost" ? "127.0.0.1" : url.hostname.replace(/^\[(.*)]$/, "$1");
44
- const port = url.port || "8787";
50
+ const port = url.port || "80";
45
51
  return { host, port };
46
52
  } catch {
47
53
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raquezha/noheadroom",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",