@raquezha/noheadroom 0.0.2

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.
@@ -0,0 +1,241 @@
1
+ import type { ImageContent, TextContent, ToolCall } from "@earendil-works/pi-ai";
2
+ import type {
3
+ AgentMessage,
4
+ ApplyCompressionOptions,
5
+ ApplyCompressionResult,
6
+ CompressionMapping,
7
+ CompressionPayload,
8
+ OpenAIAssistantMessage,
9
+ OpenAIMessage,
10
+ OpenAIToolCall,
11
+ } from "./types.js";
12
+
13
+ type AnyMessage = AgentMessage & { role?: string; content?: unknown; timestamp?: number };
14
+
15
+ interface MessageWithContent {
16
+ content: unknown;
17
+ }
18
+
19
+ const STANDARD_COMPRESSIBLE_ROLES = new Set(["user", "assistant", "toolResult"]);
20
+
21
+ export function buildCompressionPayload(messages: AgentMessage[], minMessageChars: number): CompressionPayload {
22
+ const mappings: CompressionMapping[] = [];
23
+ let candidateCount = 0;
24
+
25
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
26
+ const source = messages[sourceIndex] as AnyMessage;
27
+ const converted = convertMessage(source);
28
+ if (!converted) continue;
29
+
30
+ const originalText = extractOpenAIText(converted);
31
+ const applyTo = source.role === "toolResult" && originalText.length >= minMessageChars ? "toolResult" : null;
32
+ if (applyTo) candidateCount++;
33
+ mappings.push({ sourceIndex, message: converted, applyTo, originalText });
34
+ }
35
+
36
+ return {
37
+ messages: mappings.map((mapping) => mapping.message),
38
+ mappings,
39
+ candidateCount,
40
+ };
41
+ }
42
+
43
+ export function applyCompressionResult(
44
+ originalMessages: AgentMessage[],
45
+ mappings: CompressionMapping[],
46
+ compressedMessages: OpenAIMessage[],
47
+ _options: ApplyCompressionOptions,
48
+ ): ApplyCompressionResult {
49
+ if (compressedMessages.length !== mappings.length) {
50
+ return { ok: false, reason: "message-count-changed" };
51
+ }
52
+
53
+ const nextMessages = structuredClone(originalMessages) as AgentMessage[];
54
+ let appliedMessages = 0;
55
+
56
+ for (let index = 0; index < mappings.length; index++) {
57
+ const mapping = mappings[index];
58
+ const compressed = compressedMessages[index];
59
+ const validation = validateAlignedMessage(mapping.message, compressed);
60
+ if (!validation.ok) return validation;
61
+
62
+ const nextText = extractOpenAIText(compressed);
63
+ if (nextText === mapping.originalText) continue;
64
+
65
+ if (mapping.applyTo !== "toolResult") {
66
+ return { ok: false, reason: `non-candidate-changed:${mapping.message.role}` };
67
+ }
68
+
69
+ const target = nextMessages[mapping.sourceIndex] as AnyMessage;
70
+ if (target.role !== "toolResult") {
71
+ return { ok: false, reason: "source-role-mismatch" };
72
+ }
73
+
74
+ if (!replaceTextContent(target, nextText)) {
75
+ return { ok: false, reason: "target-content-unreplaceable" };
76
+ }
77
+ appliedMessages++;
78
+ }
79
+
80
+ if (appliedMessages === 0) {
81
+ return { ok: false, reason: "no-applicable-message-changed" };
82
+ }
83
+
84
+ return { ok: true, messages: nextMessages, appliedMessages };
85
+ }
86
+
87
+ function convertMessage(message: AnyMessage): OpenAIMessage | undefined {
88
+ if (!message.role || !STANDARD_COMPRESSIBLE_ROLES.has(message.role)) return undefined;
89
+ switch (message.role) {
90
+ case "user":
91
+ return convertUserMessage(message);
92
+ case "assistant":
93
+ return convertAssistantMessage(message);
94
+ case "toolResult":
95
+ return convertToolResultMessage(message);
96
+ default:
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ function convertUserMessage(message: AnyMessage): OpenAIMessage | undefined {
102
+ if (!hasContent(message)) return undefined;
103
+ if (typeof message.content === "string") {
104
+ return { role: "user", content: message.content };
105
+ }
106
+ if (!Array.isArray(message.content)) return undefined;
107
+
108
+ const text = message.content
109
+ .filter((part): part is TextContent => isTextContent(part))
110
+ .map((part) => part.text)
111
+ .join("\n");
112
+ if (text) return { role: "user", content: text };
113
+ const hasImages = message.content.some((part) => isImageContent(part));
114
+ return hasImages ? { role: "user", content: "[image omitted from Headroom compression payload]" } : undefined;
115
+ }
116
+
117
+ function convertAssistantMessage(message: AnyMessage): OpenAIAssistantMessage | undefined {
118
+ if (!hasContent(message) || !Array.isArray(message.content)) return undefined;
119
+ const text = message.content
120
+ .filter((part): part is TextContent => isTextContent(part))
121
+ .map((part) => part.text)
122
+ .join("");
123
+ const toolCalls = message.content.filter((part): part is ToolCall => isToolCall(part));
124
+ if (!text && toolCalls.length === 0) return undefined;
125
+
126
+ const converted: OpenAIAssistantMessage = {
127
+ role: "assistant",
128
+ content: text || null,
129
+ };
130
+ if (toolCalls.length > 0) {
131
+ converted.tool_calls = toolCalls.map(convertToolCall);
132
+ }
133
+ return converted;
134
+ }
135
+
136
+ function convertToolResultMessage(message: AnyMessage): OpenAIMessage | undefined {
137
+ if (!hasContent(message)) return undefined;
138
+ const toolCallId = readStringProperty(message, "toolCallId");
139
+ if (!toolCallId) return undefined;
140
+ return {
141
+ role: "tool",
142
+ content: extractTextFromContent(message.content),
143
+ tool_call_id: toolCallId,
144
+ };
145
+ }
146
+
147
+ function convertToolCall(toolCall: ToolCall): OpenAIToolCall {
148
+ return {
149
+ id: toolCall.id,
150
+ type: "function",
151
+ function: {
152
+ // Headroom may protect/exclude built-in agent tool names such as `read` and `bash`.
153
+ // This payload is only for compression alignment; Pi's original tool metadata is preserved
154
+ // in originalMessages and restored when applying the compressed toolResult text.
155
+ name: "pi_tool_result",
156
+ arguments: JSON.stringify({ originalToolName: toolCall.name }),
157
+ },
158
+ };
159
+ }
160
+
161
+ function validateAlignedMessage(
162
+ original: OpenAIMessage,
163
+ compressed: OpenAIMessage,
164
+ ): { ok: true } | { ok: false; reason: string } {
165
+ if (original.role !== compressed.role) {
166
+ return { ok: false, reason: `role-changed:${original.role}->${compressed.role}` };
167
+ }
168
+ if (original.role === "tool" && compressed.role === "tool") {
169
+ if (original.tool_call_id !== compressed.tool_call_id) return { ok: false, reason: "tool-call-id-changed" };
170
+ }
171
+ if (original.role === "assistant" && compressed.role === "assistant") {
172
+ const originalIds = (original.tool_calls ?? []).map((call) => call.id).join("\n");
173
+ const compressedIds = (compressed.tool_calls ?? []).map((call) => call.id).join("\n");
174
+ if (originalIds !== compressedIds) return { ok: false, reason: "assistant-tool-calls-changed" };
175
+ }
176
+ return { ok: true };
177
+ }
178
+
179
+ function extractOpenAIText(message: OpenAIMessage): string {
180
+ if (message.role === "assistant") return typeof message.content === "string" ? message.content : "";
181
+ if (message.role === "tool" || message.role === "system") return message.content;
182
+ if (typeof message.content === "string") return message.content;
183
+ return message.content
184
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
185
+ .map((part) => part.text)
186
+ .join("\n");
187
+ }
188
+
189
+ function extractTextFromContent(content: unknown): string {
190
+ if (typeof content === "string") return content;
191
+ if (!Array.isArray(content)) return "";
192
+ return content
193
+ .filter((part): part is TextContent => isTextContent(part))
194
+ .map((part) => part.text)
195
+ .join("\n");
196
+ }
197
+
198
+ function replaceTextContent(message: MessageWithContent, text: string): boolean {
199
+ if (typeof message.content === "string") {
200
+ message.content = text;
201
+ return true;
202
+ }
203
+ if (!Array.isArray(message.content)) return false;
204
+ const imageParts = message.content.filter((part): part is ImageContent => isImageContent(part));
205
+ message.content = [{ type: "text", text }, ...imageParts];
206
+ return true;
207
+ }
208
+
209
+ function hasContent(message: AnyMessage): message is AnyMessage & MessageWithContent {
210
+ return "content" in message;
211
+ }
212
+
213
+ function isTextContent(value: unknown): value is TextContent {
214
+ return isRecord(value) && value.type === "text" && typeof value.text === "string";
215
+ }
216
+
217
+ function isImageContent(value: unknown): value is ImageContent {
218
+ return (
219
+ isRecord(value) && value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string"
220
+ );
221
+ }
222
+
223
+ function isToolCall(value: unknown): value is ToolCall {
224
+ return (
225
+ isRecord(value) &&
226
+ value.type === "toolCall" &&
227
+ typeof value.id === "string" &&
228
+ typeof value.name === "string" &&
229
+ isRecord(value.arguments)
230
+ );
231
+ }
232
+
233
+ function readStringProperty(value: unknown, key: string): string | undefined {
234
+ if (!isRecord(value)) return undefined;
235
+ const property = value[key];
236
+ return typeof property === "string" ? property : undefined;
237
+ }
238
+
239
+ function isRecord(value: unknown): value is Record<string, unknown> {
240
+ return typeof value === "object" && value !== null;
241
+ }
@@ -0,0 +1,112 @@
1
+ import type { CompressResult, OpenAIMessage } from "./types.js";
2
+
3
+ interface ProxyCompressResponse {
4
+ messages: OpenAIMessage[];
5
+ tokens_before: number;
6
+ tokens_after: number;
7
+ tokens_saved: number;
8
+ compression_ratio: number;
9
+ transforms_applied: string[];
10
+ ccr_hashes?: string[];
11
+ }
12
+
13
+ interface HeadroomClientOptions {
14
+ baseUrl: string;
15
+ timeoutMs: number;
16
+ }
17
+
18
+ export class HeadroomHttpClient {
19
+ private readonly baseUrl: string;
20
+ private readonly timeoutMs: number;
21
+
22
+ constructor(options: HeadroomClientOptions) {
23
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
24
+ this.timeoutMs = options.timeoutMs;
25
+ }
26
+
27
+ async health(signal?: AbortSignal): Promise<boolean> {
28
+ try {
29
+ const response = await fetch(`${this.baseUrl}/health`, {
30
+ signal: buildSignal(this.timeoutMs, signal),
31
+ });
32
+ if (!response.ok) return false;
33
+ const body = await readJsonObject(response);
34
+ if (!body) return true;
35
+ return body.status === "healthy" || body.status === "ok" || "optimize" in body || "stats" in body;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ async stats(signal?: AbortSignal): Promise<unknown> {
42
+ const response = await fetch(`${this.baseUrl}/stats`, {
43
+ signal: buildSignal(this.timeoutMs, signal),
44
+ });
45
+ if (!response.ok) {
46
+ throw new Error(`Headroom /stats failed with HTTP ${response.status}`);
47
+ }
48
+ return response.json();
49
+ }
50
+
51
+ async compress(messages: OpenAIMessage[], model: string | undefined, signal?: AbortSignal): Promise<CompressResult> {
52
+ const response = await fetch(`${this.baseUrl}/v1/compress`, {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ "X-Headroom-Stack": "pi-extension",
57
+ },
58
+ body: JSON.stringify({ messages, model: model || "gpt-4o" }),
59
+ signal: buildSignal(this.timeoutMs, signal),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ const message = await readErrorMessage(response);
64
+ throw new Error(message || `Headroom /v1/compress failed with HTTP ${response.status}`);
65
+ }
66
+
67
+ const payload = (await response.json()) as ProxyCompressResponse;
68
+ return {
69
+ messages: payload.messages,
70
+ tokensBefore: payload.tokens_before,
71
+ tokensAfter: payload.tokens_after,
72
+ tokensSaved: payload.tokens_saved,
73
+ compressionRatio: payload.compression_ratio,
74
+ transformsApplied: payload.transforms_applied ?? [],
75
+ ccrHashes: payload.ccr_hashes ?? [],
76
+ compressed: true,
77
+ };
78
+ }
79
+ }
80
+
81
+ async function readJsonObject(response: Response): Promise<Record<string, unknown> | undefined> {
82
+ try {
83
+ const body = (await response.json()) as unknown;
84
+ return isRecord(body) ? body : undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ async function readErrorMessage(response: Response): Promise<string | undefined> {
91
+ try {
92
+ const body = (await response.json()) as unknown;
93
+ if (!isRecord(body)) return undefined;
94
+ const error = body.error;
95
+ if (isRecord(error) && typeof error.message === "string") return error.message;
96
+ if (typeof body.message === "string") return body.message;
97
+ } catch {
98
+ // Ignore malformed error bodies.
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function buildSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
104
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
105
+ if (!signal) return timeoutSignal;
106
+ if (signal.aborted) return signal;
107
+ return AbortSignal.any([signal, timeoutSignal]);
108
+ }
109
+
110
+ function isRecord(value: unknown): value is Record<string, unknown> {
111
+ return typeof value === "object" && value !== null;
112
+ }
@@ -0,0 +1,101 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { HeadroomConfig } from "./types.js";
5
+
6
+ const DEFAULT_BASE_URL = "http://127.0.0.1:8788";
7
+ const DEFAULT_MIN_CONTEXT_TOKENS = 20_000;
8
+ const DEFAULT_MIN_MESSAGE_CHARS = 2_000;
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+
11
+ export const HEADROOM_SETTINGS_DIR = path.join(os.homedir(), ".pi", "agent", "headroom");
12
+ export const HEADROOM_SETTINGS_FILE = path.join(HEADROOM_SETTINGS_DIR, "settings.json");
13
+
14
+ export interface HeadroomSettings {
15
+ enabled?: boolean | string;
16
+ baseUrl?: string;
17
+ url?: string;
18
+ allowRemote?: boolean | string;
19
+ autoStart?: boolean | string;
20
+ command?: string;
21
+ minContextTokens?: number | string;
22
+ minMessageChars?: number | string;
23
+ timeoutMs?: number | string;
24
+ }
25
+
26
+ export function loadHeadroomSettings(settingsPath: string = HEADROOM_SETTINGS_FILE): HeadroomSettings {
27
+ try {
28
+ const raw = fs.readFileSync(settingsPath, "utf-8");
29
+ const parsed = JSON.parse(raw) as unknown;
30
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as HeadroomSettings;
31
+ } catch {
32
+ // Missing or invalid settings.json falls back to env/defaults.
33
+ }
34
+ return {};
35
+ }
36
+
37
+ export function loadHeadroomConfig(
38
+ env: NodeJS.ProcessEnv = process.env,
39
+ settings: HeadroomSettings = env === process.env ? loadHeadroomSettings() : {},
40
+ ): HeadroomConfig {
41
+ const envBaseUrl = env.PI_HEADROOM_URL || env.HEADROOM_URL || env.HEADROOM_BASE_URL || DEFAULT_BASE_URL;
42
+ const baseUrl = normalizeBaseUrl(parseString(settings.baseUrl ?? settings.url, envBaseUrl));
43
+ return {
44
+ enabled: parseBoolean(settings.enabled, parseBoolean(env.PI_HEADROOM_ENABLED, true)),
45
+ baseUrl,
46
+ allowRemote: parseBoolean(settings.allowRemote, parseBoolean(env.PI_HEADROOM_ALLOW_REMOTE, false)),
47
+ autoStart: parseBoolean(settings.autoStart, parseBoolean(env.PI_HEADROOM_AUTO_START, true)),
48
+ command: parseString(settings.command, env.PI_HEADROOM_COMMAND?.trim() || "headroom"),
49
+ minContextTokens: parseInteger(
50
+ settings.minContextTokens,
51
+ parseInteger(env.PI_HEADROOM_MIN_CONTEXT_TOKENS, DEFAULT_MIN_CONTEXT_TOKENS, 0),
52
+ 0,
53
+ ),
54
+ minMessageChars: parseInteger(
55
+ settings.minMessageChars,
56
+ parseInteger(env.PI_HEADROOM_MIN_MESSAGE_CHARS, DEFAULT_MIN_MESSAGE_CHARS, 1),
57
+ 1,
58
+ ),
59
+ timeoutMs: parseInteger(settings.timeoutMs, parseInteger(env.PI_HEADROOM_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, 100), 100),
60
+ };
61
+ }
62
+
63
+ export function isLocalHeadroomUrl(rawUrl: string): boolean {
64
+ try {
65
+ const url = new URL(rawUrl);
66
+ return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function isRemoteBlocked(config: Pick<HeadroomConfig, "baseUrl" | "allowRemote">): boolean {
73
+ return !config.allowRemote && !isLocalHeadroomUrl(config.baseUrl);
74
+ }
75
+
76
+ function normalizeBaseUrl(raw: string): string {
77
+ const trimmed = raw.trim() || DEFAULT_BASE_URL;
78
+ return trimmed.replace(/\/+$/, "");
79
+ }
80
+
81
+ function parseString(raw: unknown, fallback: string): string {
82
+ if (typeof raw !== "string") return fallback;
83
+ return raw.trim() || fallback;
84
+ }
85
+
86
+ function parseBoolean(raw: unknown, fallback: boolean): boolean {
87
+ if (raw === undefined) return fallback;
88
+ if (typeof raw === "boolean") return raw;
89
+ if (typeof raw !== "string") return fallback;
90
+ const normalized = raw.trim().toLowerCase();
91
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
92
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
93
+ return fallback;
94
+ }
95
+
96
+ function parseInteger(raw: unknown, fallback: number, min: number): number {
97
+ if (raw === undefined) return fallback;
98
+ const parsed = typeof raw === "number" ? raw : typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
99
+ if (!Number.isFinite(parsed) || parsed < min) return fallback;
100
+ return Math.trunc(parsed);
101
+ }