@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 +2 -0
- package/dist/bridge.d.ts +1 -0
- package/dist/bridge.js +25 -3
- package/dist/config.d.ts +3 -0
- package/dist/config.js +22 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +84 -9
- package/dist/types.d.ts +2 -0
- package/extensions/bridge.ts +32 -3
- package/extensions/config.ts +33 -1
- package/extensions/index.ts +91 -9
- package/extensions/types.ts +3 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -73,12 +73,14 @@ Settings are stored in `~/.pi/agent/headroom/settings.json`:
|
|
|
73
73
|
"enabled": true,
|
|
74
74
|
"baseUrl": "http://127.0.0.1:8788",
|
|
75
75
|
"autoStart": false,
|
|
76
|
+
"mode": "normal",
|
|
76
77
|
"minContextTokens": 10000,
|
|
77
78
|
"minMessageChars": 2000
|
|
78
79
|
}
|
|
79
80
|
```
|
|
80
81
|
|
|
81
82
|
- **`autoStart`**: Set to `false` when using the Docker backend.
|
|
83
|
+
- **`mode`**: `normal` (all output), `quiet` (suppress routine success), or `silent` (suppress all non-critical notices). Override with `PI_HEADROOM_MODE=quiet`.
|
|
82
84
|
- **`minContextTokens`**: Compression kicks in once the context reaches this size.
|
|
83
85
|
|
|
84
86
|
## 🛡 Privacy & Security
|
package/dist/bridge.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export declare function buildCompressionPayload(messages: AgentMessage[], minMes
|
|
|
8
8
|
export declare function applyCompressionResult(originalMessages: AgentMessage[], mappings: CompressionMapping[], compressedMessages: OpenAIMessage[], _options: ApplyCompressionOptions): ApplyCompressionResult;
|
|
9
9
|
export declare function convertMessage(message: AnyMessage): OpenAIMessage | undefined;
|
|
10
10
|
export declare function extractOpenAIText(message: OpenAIMessage): string;
|
|
11
|
+
export declare function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[];
|
|
11
12
|
export {};
|
package/dist/bridge.js
CHANGED
|
@@ -46,6 +46,8 @@ export function applyCompressionResult(originalMessages, mappings, compressedMes
|
|
|
46
46
|
let nextText = extractOpenAIText(compressed);
|
|
47
47
|
if (nextText === mapping.originalText)
|
|
48
48
|
continue;
|
|
49
|
+
// Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
|
|
50
|
+
nextText = naturalizeHeadroomMarkers(nextText);
|
|
49
51
|
const target = nextMessages[mapping.sourceIndex];
|
|
50
52
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
51
53
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
@@ -124,9 +126,8 @@ function convertToolCall(toolCall) {
|
|
|
124
126
|
id: toolCall.id,
|
|
125
127
|
type: "function",
|
|
126
128
|
function: {
|
|
127
|
-
// Headroom
|
|
128
|
-
//
|
|
129
|
-
// in originalMessages and restored when applying the compressed toolResult text.
|
|
129
|
+
// Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
|
|
130
|
+
// Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
|
|
130
131
|
name: "pi_tool_result",
|
|
131
132
|
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
132
133
|
},
|
|
@@ -220,3 +221,24 @@ function readStringProperty(value, key) {
|
|
|
220
221
|
function isRecord(value) {
|
|
221
222
|
return typeof value === "object" && value !== null;
|
|
222
223
|
}
|
|
224
|
+
function naturalizeHeadroomMarkers(text) {
|
|
225
|
+
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
226
|
+
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
227
|
+
return text.replace(/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi, "[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]");
|
|
228
|
+
}
|
|
229
|
+
export function injectCompressionAwareness(messages) {
|
|
230
|
+
// If a system message already exists at the top, we append the hint to it.
|
|
231
|
+
// Otherwise, we inject a new system message at index 0.
|
|
232
|
+
const result = [...messages];
|
|
233
|
+
const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
|
|
234
|
+
const first = result[0];
|
|
235
|
+
if (first && first.role === "system" && typeof first.content === "string") {
|
|
236
|
+
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
237
|
+
result[0] = { ...first, content: `${first.content}\n\n${hintText}` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
result.unshift({ role: "system", content: hintText });
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -7,12 +7,15 @@ export interface HeadroomSettings {
|
|
|
7
7
|
url?: string;
|
|
8
8
|
allowRemote?: boolean | string;
|
|
9
9
|
autoStart?: boolean | string;
|
|
10
|
+
mode?: string;
|
|
11
|
+
silent?: boolean | string;
|
|
10
12
|
command?: string;
|
|
11
13
|
minContextTokens?: number | string;
|
|
12
14
|
minMessageChars?: number | string;
|
|
13
15
|
timeoutMs?: number | string;
|
|
14
16
|
}
|
|
15
17
|
export declare function loadHeadroomSettings(settingsPath?: string): HeadroomSettings;
|
|
18
|
+
export declare function saveHeadroomSettings(patch: Partial<HeadroomSettings>, settingsPath?: string): void;
|
|
16
19
|
export declare function loadHeadroomConfig(env?: NodeJS.ProcessEnv, settings?: HeadroomSettings): HeadroomConfig;
|
|
17
20
|
export declare function isLocalHeadroomUrl(rawUrl: string): boolean;
|
|
18
21
|
export declare function isRemoteBlocked(config: Pick<HeadroomConfig, "baseUrl" | "allowRemote">): boolean;
|
package/dist/config.js
CHANGED
|
@@ -19,6 +19,17 @@ export function loadHeadroomSettings(settingsPath = HEADROOM_SETTINGS_FILE) {
|
|
|
19
19
|
}
|
|
20
20
|
return {};
|
|
21
21
|
}
|
|
22
|
+
export function saveHeadroomSettings(patch, settingsPath = HEADROOM_SETTINGS_FILE) {
|
|
23
|
+
const current = loadHeadroomSettings(settingsPath);
|
|
24
|
+
const updated = { ...current, ...patch };
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.warn(`[noheadroom] Failed to save settings to ${settingsPath}:`, error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
22
33
|
export function loadHeadroomConfig(env = process.env, settings = env === process.env ? loadHeadroomSettings() : {}) {
|
|
23
34
|
const envBaseUrl = env.PI_HEADROOM_URL || env.HEADROOM_URL || env.HEADROOM_BASE_URL || DEFAULT_BASE_URL;
|
|
24
35
|
const baseUrl = normalizeBaseUrl(parseString(settings.baseUrl ?? settings.url, envBaseUrl));
|
|
@@ -27,6 +38,7 @@ export function loadHeadroomConfig(env = process.env, settings = env === process
|
|
|
27
38
|
baseUrl,
|
|
28
39
|
allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
|
|
29
40
|
autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
|
|
41
|
+
mode: parseMode(settings.mode, env.PI_HEADROOM_MODE, parseBoolean(settings.silent, parseBoolean(env.PI_HEADROOM_SILENT, false)) ? "silent" : "normal"),
|
|
30
42
|
command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
|
|
31
43
|
minContextTokens: parseInteger(settings.minContextTokens, parseInteger(env.PI_HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0), 0),
|
|
32
44
|
minMessageChars: parseInteger(settings.minMessageChars, parseInteger(env.PI_HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1), 1),
|
|
@@ -76,3 +88,13 @@ function parseInteger(raw, fallback, min) {
|
|
|
76
88
|
return fallback;
|
|
77
89
|
return Math.trunc(parsed);
|
|
78
90
|
}
|
|
91
|
+
const HEADROOM_MODES = ["normal", "quiet", "silent"];
|
|
92
|
+
function parseMode(raw, envRaw, fallback) {
|
|
93
|
+
const normalize = (v) => {
|
|
94
|
+
if (typeof v !== "string")
|
|
95
|
+
return null;
|
|
96
|
+
const s = v.trim().toLowerCase();
|
|
97
|
+
return HEADROOM_MODES.includes(s) ? s : null;
|
|
98
|
+
};
|
|
99
|
+
return normalize(raw) ?? normalize(envRaw) ?? fallback;
|
|
100
|
+
}
|
package/dist/index.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
|
|
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) =>
|
|
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;
|
package/extensions/bridge.ts
CHANGED
|
@@ -77,6 +77,9 @@ export function applyCompressionResult(
|
|
|
77
77
|
let nextText = extractOpenAIText(compressed);
|
|
78
78
|
if (nextText === mapping.originalText) continue;
|
|
79
79
|
|
|
80
|
+
// Naturalize Headroom's native CCR markers to provide Pi-native tooling hints (Upstream #846)
|
|
81
|
+
nextText = naturalizeHeadroomMarkers(nextText);
|
|
82
|
+
|
|
80
83
|
const target = nextMessages[mapping.sourceIndex] as AnyMessage;
|
|
81
84
|
if (!hasContent(target) || !replaceTextContent(target, nextText)) {
|
|
82
85
|
return { ok: false, reason: "target-content-unreplaceable" };
|
|
@@ -156,9 +159,8 @@ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
|
|
|
156
159
|
id: toolCall.id,
|
|
157
160
|
type: "function",
|
|
158
161
|
function: {
|
|
159
|
-
// Headroom
|
|
160
|
-
//
|
|
161
|
-
// in originalMessages and restored when applying the compressed toolResult text.
|
|
162
|
+
// Headroom protects exact tool names (read, bash) in its DEFAULT_EXCLUDE_TOOLS.
|
|
163
|
+
// Keep upstream bridge behavior until tests prove a better name changes routing/fidelity.
|
|
162
164
|
name: "pi_tool_result",
|
|
163
165
|
arguments: JSON.stringify({ originalToolName: toolCall.name }),
|
|
164
166
|
},
|
|
@@ -264,3 +266,30 @@ function readStringProperty(value: unknown, key: string): string | undefined {
|
|
|
264
266
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
265
267
|
return typeof value === "object" && value !== null;
|
|
266
268
|
}
|
|
269
|
+
|
|
270
|
+
function naturalizeHeadroomMarkers(text: string): string {
|
|
271
|
+
// Translate Headroom's "[X items compressed to Y. Retrieve more: hash=...]"
|
|
272
|
+
// into a Pi-native instruction to use offset/limit instead of hallucinating a retrieve command.
|
|
273
|
+
return text.replace(
|
|
274
|
+
/\[(.*?(?:compressed|omitted).*?)\.?\s*Retrieve more: hash=[a-f0-9]+\]/gi,
|
|
275
|
+
"[$1. Hint: Do not re-read the whole file. Use the 'read' tool with 'offset' and 'limit' to inspect specific sections.]"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function injectCompressionAwareness(messages: AgentMessage[]): AgentMessage[] {
|
|
280
|
+
// If a system message already exists at the top, we append the hint to it.
|
|
281
|
+
// Otherwise, we inject a new system message at index 0.
|
|
282
|
+
const result = [...messages];
|
|
283
|
+
const hintText = "Environment Hint: Some tool results in this context have been automatically compressed by Headroom to optimize tokens. If you see '[X items compressed to Y]' or similar markers, do NOT attempt to re-read the entire file. Use the 'read' tool with 'offset' and 'limit' to query missing sections.";
|
|
284
|
+
|
|
285
|
+
const first = result[0] as unknown as Record<string, unknown>;
|
|
286
|
+
if (first && first.role === "system" && typeof first.content === "string") {
|
|
287
|
+
if (!first.content.includes("automatically compressed by Headroom")) {
|
|
288
|
+
result[0] = { ...first, content: `${first.content}\n\n${hintText}` } as unknown as AgentMessage;
|
|
289
|
+
}
|
|
290
|
+
} else {
|
|
291
|
+
result.unshift({ role: "system", content: hintText } as unknown as AgentMessage);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return result;
|
|
295
|
+
}
|
package/extensions/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import type { HeadroomConfig } from "./types.js";
|
|
4
|
+
import type { HeadroomConfig, HeadroomMode } from "./types.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
|
|
7
7
|
const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
|
|
@@ -17,6 +17,8 @@ export interface HeadroomSettings {
|
|
|
17
17
|
url?: string;
|
|
18
18
|
allowRemote?: boolean | string;
|
|
19
19
|
autoStart?: boolean | string;
|
|
20
|
+
mode?: string;
|
|
21
|
+
silent?: boolean | string;
|
|
20
22
|
command?: string;
|
|
21
23
|
minContextTokens?: number | string;
|
|
22
24
|
minMessageChars?: number | string;
|
|
@@ -34,6 +36,20 @@ export function loadHeadroomSettings(settingsPath: string = HEADROOM_SETTINGS_FI
|
|
|
34
36
|
return {};
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export function saveHeadroomSettings(
|
|
40
|
+
patch: Partial<HeadroomSettings>,
|
|
41
|
+
settingsPath: string = HEADROOM_SETTINGS_FILE,
|
|
42
|
+
): void {
|
|
43
|
+
const current = loadHeadroomSettings(settingsPath);
|
|
44
|
+
const updated = { ...current, ...patch };
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
47
|
+
fs.writeFileSync(settingsPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn(`[noheadroom] Failed to save settings to ${settingsPath}:`, error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export function loadHeadroomConfig(
|
|
38
54
|
env: NodeJS.ProcessEnv = process.env,
|
|
39
55
|
settings: HeadroomSettings = env === process.env ? loadHeadroomSettings() : {},
|
|
@@ -45,6 +61,11 @@ export function loadHeadroomConfig(
|
|
|
45
61
|
baseUrl,
|
|
46
62
|
allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
|
|
47
63
|
autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
|
|
64
|
+
mode: parseMode(
|
|
65
|
+
settings.mode,
|
|
66
|
+
env.PI_HEADROOM_MODE,
|
|
67
|
+
parseBoolean(settings.silent, parseBoolean(env.PI_HEADROOM_SILENT, false)) ? "silent" : "normal",
|
|
68
|
+
),
|
|
48
69
|
command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
|
|
49
70
|
minContextTokens: parseInteger(
|
|
50
71
|
settings.minContextTokens,
|
|
@@ -99,3 +120,14 @@ function parseInteger(raw: unknown, fallback: number, min: number): number {
|
|
|
99
120
|
if (!Number.isFinite(parsed) || parsed < min) return fallback;
|
|
100
121
|
return Math.trunc(parsed);
|
|
101
122
|
}
|
|
123
|
+
|
|
124
|
+
const HEADROOM_MODES: HeadroomMode[] = ["normal", "quiet", "silent"];
|
|
125
|
+
|
|
126
|
+
function parseMode(raw: unknown, envRaw: string | undefined, fallback: HeadroomMode): HeadroomMode {
|
|
127
|
+
const normalize = (v: unknown): HeadroomMode | null => {
|
|
128
|
+
if (typeof v !== "string") return null;
|
|
129
|
+
const s = v.trim().toLowerCase() as HeadroomMode;
|
|
130
|
+
return HEADROOM_MODES.includes(s) ? s : null;
|
|
131
|
+
};
|
|
132
|
+
return normalize(raw) ?? normalize(envRaw) ?? fallback;
|
|
133
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -8,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
|
|
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) =>
|
|
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:",
|
package/extensions/types.ts
CHANGED
|
@@ -58,11 +58,14 @@ export interface CompressResult {
|
|
|
58
58
|
compressed: boolean;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export type HeadroomMode = "normal" | "quiet" | "silent";
|
|
62
|
+
|
|
61
63
|
export interface HeadroomConfig {
|
|
62
64
|
enabled: boolean;
|
|
63
65
|
baseUrl: string;
|
|
64
66
|
allowRemote: boolean;
|
|
65
67
|
autoStart: boolean;
|
|
68
|
+
mode: HeadroomMode;
|
|
66
69
|
command: string;
|
|
67
70
|
minContextTokens: number;
|
|
68
71
|
minMessageChars: number;
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raquezha/noheadroom",
|
|
3
|
-
"version": "0.
|
|
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": {
|