@narumitw/pi-webui 0.20.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Narumi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # 🌐 pi-webui — Current-session Web Companion for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-webui)](https://www.npmjs.com/package/@narumitw/pi-webui) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-webui` adds a private, lightweight browser companion to the current terminal-owned [Pi Coding Agent](https://pi.dev) session. It displays Pi's semantic conversation and tool activity as they happen and can send text or sanitized images back into that same session.
6
+
7
+ This package is intentionally different from the broader, separately maintained `@narumitw/pi-web` application. WebUI has one current-session chat page and no session manager, shell, file browser, git UI, control room, or task board.
8
+
9
+ ## Features
10
+
11
+ - Streams current-branch user and assistant messages, assistant text updates, tool activity/results, errors, and busy/idle state over Server-Sent Events.
12
+ - Renders a safe Markdown subset for headings, lists, emphasis, code, blockquotes, and HTTP(S) links without executing model-provided HTML.
13
+ - Preserves open tool/thinking disclosures during keyed streaming updates and offers **Jump to latest** when new activity arrives while you read earlier messages.
14
+ - Sends immediately while Pi is idle and automatically queues **Queue next** as a follow-up while Pi is busy.
15
+ - Provides a separate **Steer** action while Pi is working; steering is never the default submit action.
16
+ - Accepts pasted, dropped, or selected PNG, JPEG, WebP, and GIF images, strips metadata server-side, applies Pi-compatible size limits, and provides contained thumbnails plus an enlarged preview.
17
+ - Reconnects from an ordered event cursor and replaces state from an authoritative snapshot after a gap.
18
+ - Keeps a failed browser draft and prevents rapid duplicate submission with request IDs.
19
+ - Uses no frontend framework, build step, browser storage, remote service, or automatically launched browser.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pi install npm:@narumitw/pi-webui
25
+ ```
26
+
27
+ Try the working tree without installing:
28
+
29
+ ```bash
30
+ pi -e ./extensions/pi-webui
31
+ # or
32
+ just try webui
33
+ ```
34
+
35
+ The package targets the latest Pi release.
36
+
37
+ ## Usage
38
+
39
+ 1. Start Pi in a terminal and run `/webui`.
40
+ 2. Open the one-time `http://127.0.0.1:<port>/bootstrap?...` link shown by Pi. The extension does not open a browser itself.
41
+ 3. Continue typing in either the terminal or browser. Accepted messages from both surfaces appear in the browser transcript.
42
+ 4. While Pi is idle, **Send** starts a turn immediately. While Pi is working, **Queue next** queues a follow-up. Use **Steer** only when the new instruction should reach Pi after the current tool batch.
43
+ 5. Refreshing or opening the link in another tab takes the editing lease. Older tabs remain readable and clearly become read-only.
44
+
45
+ If another installed extension also registers `/webui`, Pi assigns numeric command suffixes according to extension load order. Check Pi's command provenance and invoke the WebUI entry.
46
+
47
+ ## Commands
48
+
49
+ | Command | Behavior |
50
+ | --- | --- |
51
+ | `/webui` | Start or reuse the current session server and display a fresh one-time link. |
52
+ | `/webui settings` | Open the interactive settings screen in TUI mode. Other modes report the manual settings path when notifications are available. |
53
+ | `/webui status` | Show the effective startup preference and source, settings path, and whether the current session server is running. It never issues a bootstrap link. |
54
+ | `/webui help` | Show command and manual-settings help. |
55
+ | `/webui init` | Create the defaults file without overwriting existing content, then open settings in TUI mode. |
56
+
57
+ Argument completion is available for all subcommands. Bare `/webui` remains the direct browser-link action.
58
+
59
+ ## Settings
60
+
61
+ WebUI has one optional, **global-only** JSON settings file:
62
+
63
+ ```text
64
+ <getAgentDir()>/pi-webui.json
65
+ ```
66
+
67
+ The normal default path is `~/.pi/agent/pi-webui.json`. Pi installations that use another agent directory are resolved through Pi's `getAgentDir()` API; WebUI adds no environment variable or project override.
68
+
69
+ ```json
70
+ {
71
+ "startOnSessionStart": false
72
+ }
73
+ ```
74
+
75
+ | Setting | Default | Behavior |
76
+ | --- | --- | --- |
77
+ | `startOnSessionStart` | `false` | Start WebUI and display a fresh one-time link after every Pi session initialization, including startup, reload, new, resume, and fork. It never opens a browser. |
78
+
79
+ A missing file uses defaults. The file must contain a top-level JSON object, and `startOnSessionStart` must be a boolean when present. Malformed JSON or an invalid recognized value causes the file to be ignored with a warning and leaves it untouched. Unknown fields are accepted and preserved by the settings screen for forward compatibility.
80
+
81
+ Settings are reloaded on every `session_start`. Changes made in `/webui settings` are saved atomically and update the in-memory preference immediately, but they intentionally do not start or stop the server in the current session; they take effect at the next session initialization or `/reload`. `/webui init` creates formatted defaults once and refuses to overwrite valid or invalid existing content.
82
+
83
+ In print, JSON, and RPC modes, `/webui settings` does not open custom TUI or write protocol-breaking output. Use `/webui status`, `/webui help`, or edit the reported path manually.
84
+
85
+ ## What synchronization means
86
+
87
+ WebUI mirrors Pi's semantic session events, not terminal pixels. It displays conversation content, streaming assistant state, tool calls/results, errors, and activity using browser-native presentation. It does not reproduce ANSI colors, terminal wrapping, footer/widgets, built-in dialogs, arbitrary custom TUI components, or unsent terminal editor text.
88
+
89
+ The initial transcript comes from the active session branch. Browser refresh does not create a second transcript or alter Pi's session tree.
90
+
91
+ ## Images
92
+
93
+ | Input | Provider-ready output |
94
+ | --- | --- |
95
+ | PNG | PNG |
96
+ | JPEG | JPEG |
97
+ | WebP | WebP |
98
+ | GIF | GIF |
99
+
100
+ The server checks file signatures instead of trusting browser MIME types. It rejects corrupt/unknown formats, more than 8 images, individual source images over 10 MiB, combined image input over 40 MiB, images over 50 megapixels, and provider-ready Base64 content over Pi's approximately 4.5 MB inline limit. Images over 2,000 pixels on either side are resized when Pi's `images.autoResize` setting is enabled and rejected when it is disabled.
101
+
102
+ Processing re-encodes images with `sharp`, strips EXIF and other metadata, and does not retain browser source bytes after the message request is accepted. Pi's effective global and trusted-project `images.autoResize` and `images.blockImages` settings plus the current model's image capability are checked at send time.
103
+
104
+ ## Security and privacy
105
+
106
+ - The server binds only to a random `127.0.0.1` port and is owned by one live Pi session.
107
+ - A rotating bootstrap token is exchanged once for a per-server HttpOnly, `SameSite=Strict` cookie and removed from the URL.
108
+ - Every endpoint requires the cookie. Mutations also require exact Host and Origin values plus the active browser-tab lease.
109
+ - Responses use no-store, no-referrer, MIME-sniffing, frame-denial, same-origin resource, and restrictive Content Security Policy headers.
110
+ - Transcript projection retains at most the newest 500 messages and 500 tool records, event replay keeps 256 updates, and request-id records keep 128 sends. The page uses no localStorage, sessionStorage, IndexedDB, cookies of its own, or image/transcript cache.
111
+ - Tool arguments/results, paths, images, and model thinking can be sensitive. Thinking is collapsed by default; only open a link issued by a Pi process you trust.
112
+ - Reload, session replacement/fork, or Pi shutdown closes sockets, invalidates old callbacks, ends the page, and releases in-memory state.
113
+
114
+ A loopback page is local to the operating-system network namespace. WebUI does not support LAN/public binding or a cloud relay. For SSH, containers, or devcontainers, forward the exact printed port and preserve the `127.0.0.1:<port>` Host value.
115
+
116
+ ## Accessibility and browsers
117
+
118
+ The page uses semantic headings, native disclosure/dialog controls, concise status/alert live regions, text labels, visible keyboard focus, at least 44 px controls, keyboard image preview/removal, `Ctrl/Command+Enter` submission, reduced-motion handling, dark mode, and responsive reflow. It targets current stable desktop Chrome, Edge, Firefox, and Safari.
119
+
120
+ ## Limitations
121
+
122
+ - One active Pi session and one active browser editing tab only.
123
+ - No persistent browser transcript, sent-image history, remote access, PTY/terminal control, model/settings controls, or session switching.
124
+ - No SVG, HEIC/HEIF, TIFF, BMP, AVIF, remote image URL, OCR, annotation, or directory upload support.
125
+ - Browser acknowledgment means Pi accepted or queued the message. Provider failures follow Pi's normal session/retry behavior.
126
+ - Built-in TUI commands and dialogs are not reimplemented in the browser.
127
+
128
+ ## Package layout
129
+
130
+ ```text
131
+ src/webui.ts Pi extension entrypoint
132
+ src/runtime.ts Pi lifecycle, commands, event projection, and browser message routing
133
+ src/settings.ts global WebUI settings validation and atomic persistence
134
+ src/conversation.ts bounded transcript snapshot and ordered event replay
135
+ src/server.ts authenticated loopback HTTP/SSE server
136
+ src/images.ts bounded provider-ready image processing
137
+ src/pi-settings.ts effective Pi image settings reader
138
+ src/web/ framework-free browser page
139
+ ```
140
+
141
+ ## Development
142
+
143
+ From the repository root:
144
+
145
+ ```bash
146
+ npm --workspace @narumitw/pi-webui run check
147
+ npm test
148
+ just try webui
149
+ just pack webui
150
+ ```
151
+
152
+ The package preview must contain its manifest, license, README, TypeScript source, and static web assets, but no tests, fixtures, cache, or `node_modules`.
153
+
154
+ ## Keywords
155
+
156
+ Pi extension, Pi Coding Agent, browser companion, local web chat, terminal session sync, Server-Sent Events, image prompt, tool activity, local-first AI coding agent.
157
+
158
+ ## License
159
+
160
+ MIT
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@narumitw/pi-webui",
3
+ "version": "0.20.2",
4
+ "description": "Lightweight local web companion for the current Pi terminal session.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "web",
13
+ "chat",
14
+ "browser"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/webui.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json && npm run typecheck",
28
+ "format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "sharp": "^0.35.3"
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@biomejs/biome": "2.5.3",
41
+ "@earendil-works/pi-ai": "0.80.10",
42
+ "@earendil-works/pi-coding-agent": "0.80.10",
43
+ "@earendil-works/pi-tui": "0.80.10",
44
+ "typescript": "7.0.2"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/narumiruna/pi-extensions",
49
+ "directory": "extensions/pi-webui"
50
+ }
51
+ }
@@ -0,0 +1,374 @@
1
+ const MAX_TEXT_BYTES = 50_000;
2
+ const MAX_TRANSCRIPT_RECORDS = 500;
3
+ const DEFAULT_REPLAY_LIMIT = 256;
4
+
5
+ export interface SessionDescriptor {
6
+ id: string;
7
+ cwd: string;
8
+ projectName: string;
9
+ name?: string;
10
+ }
11
+
12
+ export type PublicContent =
13
+ | { type: "text"; text: string }
14
+ | { type: "thinking"; text: string }
15
+ | { type: "image"; mimeType?: string }
16
+ | { type: "toolCall"; id: string; name: string; arguments: unknown };
17
+
18
+ export interface PublicMessage {
19
+ id: string;
20
+ role: string;
21
+ timestamp?: number;
22
+ final: boolean;
23
+ content: PublicContent[];
24
+ toolCallId?: string;
25
+ toolName?: string;
26
+ isError?: boolean;
27
+ stopReason?: string;
28
+ errorMessage?: string;
29
+ }
30
+
31
+ export interface PublicTool {
32
+ id: string;
33
+ name: string;
34
+ phase: "start" | "update" | "end";
35
+ args?: unknown;
36
+ result?: unknown;
37
+ isError?: boolean;
38
+ }
39
+
40
+ export type ActivityState = "idle" | "running" | "ended";
41
+
42
+ export interface ConversationSnapshot {
43
+ sequence: number;
44
+ session: SessionDescriptor;
45
+ messages: PublicMessage[];
46
+ tools: PublicTool[];
47
+ activity: ActivityState;
48
+ closed: boolean;
49
+ }
50
+
51
+ export interface ConversationEvent {
52
+ sequence: number;
53
+ type: "message" | "tool" | "activity" | "session-ended" | "snapshot";
54
+ payload: unknown;
55
+ }
56
+
57
+ type Listener = (event: ConversationEvent) => void;
58
+
59
+ export class ConversationProjection {
60
+ private sequence = 0;
61
+ private readonly messages = new Map<string, PublicMessage>();
62
+ private readonly tools = new Map<string, PublicTool>();
63
+ private readonly replay: ConversationEvent[] = [];
64
+ private readonly listeners = new Set<Listener>();
65
+ private activity: ActivityState = "idle";
66
+ private closed = false;
67
+
68
+ constructor(
69
+ private readonly session: SessionDescriptor,
70
+ initialMessages: PublicMessage[] = [],
71
+ private readonly replayLimit = DEFAULT_REPLAY_LIMIT,
72
+ ) {
73
+ for (const message of initialMessages.slice(-MAX_TRANSCRIPT_RECORDS)) {
74
+ this.messages.set(message.id, clone(message));
75
+ }
76
+ }
77
+
78
+ snapshot(): ConversationSnapshot {
79
+ return {
80
+ sequence: this.sequence,
81
+ session: { ...this.session },
82
+ messages: [...this.messages.values()].map(clone),
83
+ tools: [...this.tools.values()].map(clone),
84
+ activity: this.activity,
85
+ closed: this.closed,
86
+ };
87
+ }
88
+
89
+ subscribe(listener: Listener): () => void {
90
+ this.listeners.add(listener);
91
+ return () => this.listeners.delete(listener);
92
+ }
93
+
94
+ eventsAfter(sequence: number): ConversationEvent[] | undefined {
95
+ if (!Number.isSafeInteger(sequence) || sequence < 0 || sequence > this.sequence)
96
+ return undefined;
97
+ if (sequence === this.sequence) return [];
98
+ const first = this.replay[0]?.sequence;
99
+ if (first === undefined || sequence < first - 1) return undefined;
100
+ return this.replay.filter((event) => event.sequence > sequence).map(clone);
101
+ }
102
+
103
+ replaceBranch(messages: PublicMessage[]): void {
104
+ this.messages.clear();
105
+ this.tools.clear();
106
+ for (const message of messages.slice(-MAX_TRANSCRIPT_RECORDS)) {
107
+ this.messages.set(message.id, clone(message));
108
+ }
109
+ this.publishSnapshot();
110
+ }
111
+
112
+ updateSession(patch: Pick<SessionDescriptor, "name">): void {
113
+ if (this.session.name === patch.name) return;
114
+ if (patch.name === undefined) delete this.session.name;
115
+ else this.session.name = patch.name;
116
+ this.publishSnapshot();
117
+ }
118
+
119
+ recordMessage(message: unknown, final = true, entryId?: string): void {
120
+ const projected = projectMessage(message, entryId, final);
121
+ const previous = this.messages.get(projected.id);
122
+ if (previous && JSON.stringify(previous) === JSON.stringify(projected)) return;
123
+ this.messages.set(projected.id, projected);
124
+ trimOldest(this.messages, MAX_TRANSCRIPT_RECORDS);
125
+ this.emit("message", projected);
126
+ }
127
+
128
+ recordTool(
129
+ phase: PublicTool["phase"],
130
+ id: string,
131
+ name: string,
132
+ args?: unknown,
133
+ result?: unknown,
134
+ isError?: boolean,
135
+ ): void {
136
+ const previous = this.tools.get(id);
137
+ const projectedArgs = args === undefined ? previous?.args : boundedJson(args);
138
+ const tool: PublicTool = {
139
+ id,
140
+ name,
141
+ phase,
142
+ ...(projectedArgs === undefined ? {} : { args: projectedArgs }),
143
+ ...(result === undefined ? {} : { result: boundedJson(result) }),
144
+ ...(isError === undefined ? {} : { isError }),
145
+ };
146
+ if (previous && JSON.stringify(previous) === JSON.stringify(tool)) return;
147
+ this.tools.set(id, tool);
148
+ trimOldest(this.tools, MAX_TRANSCRIPT_RECORDS);
149
+ this.emit("tool", tool);
150
+ }
151
+
152
+ setActivity(activity: ActivityState): void {
153
+ if (this.activity === activity || this.closed) return;
154
+ this.activity = activity;
155
+ this.emit("activity", { activity });
156
+ }
157
+
158
+ close(): void {
159
+ if (this.closed) return;
160
+ this.closed = true;
161
+ this.activity = "ended";
162
+ this.emit("session-ended", { activity: this.activity });
163
+ }
164
+
165
+ private emit(type: ConversationEvent["type"], payload: unknown): void {
166
+ this.rememberAndPublish({ sequence: ++this.sequence, type, payload: clone(payload) });
167
+ }
168
+
169
+ private publishSnapshot(): void {
170
+ const sequence = ++this.sequence;
171
+ this.rememberAndPublish({ sequence, type: "snapshot", payload: this.snapshot() });
172
+ }
173
+
174
+ private rememberAndPublish(event: ConversationEvent): void {
175
+ this.replay.push(event);
176
+ while (this.replay.length > Math.max(1, this.replayLimit)) this.replay.shift();
177
+ for (const listener of this.listeners) listener(clone(event));
178
+ }
179
+ }
180
+
181
+ export function projectBranchMessages(branch: unknown): PublicMessage[] {
182
+ if (!Array.isArray(branch)) return [];
183
+ const messages: PublicMessage[] = [];
184
+ for (const entry of branch) {
185
+ if (!isRecord(entry) || entry.type !== "message" || !("message" in entry)) continue;
186
+ try {
187
+ messages.push(
188
+ projectMessage(entry.message, typeof entry.id === "string" ? entry.id : undefined),
189
+ );
190
+ } catch {
191
+ // Unknown extension entries must not prevent the supported transcript from loading.
192
+ }
193
+ }
194
+ return messages;
195
+ }
196
+
197
+ export function projectMessage(message: unknown, entryId?: string, final = true): PublicMessage {
198
+ if (!isRecord(message) || typeof message.role !== "string") {
199
+ throw new Error("Invalid Pi message");
200
+ }
201
+ const timestamp = typeof message.timestamp === "number" ? message.timestamp : undefined;
202
+ const content = projectContent(message.content);
203
+ const id = entryId ?? messageId(message, timestamp, content);
204
+ return {
205
+ id,
206
+ role: message.role,
207
+ ...(timestamp === undefined ? {} : { timestamp }),
208
+ final,
209
+ content,
210
+ ...(typeof message.toolCallId === "string" ? { toolCallId: message.toolCallId } : {}),
211
+ ...(typeof message.toolName === "string" ? { toolName: message.toolName } : {}),
212
+ ...(typeof message.isError === "boolean" ? { isError: message.isError } : {}),
213
+ ...(typeof message.stopReason === "string" ? { stopReason: message.stopReason } : {}),
214
+ ...(typeof message.errorMessage === "string"
215
+ ? { errorMessage: truncateText(message.errorMessage) }
216
+ : {}),
217
+ };
218
+ }
219
+
220
+ function projectContent(content: unknown): PublicContent[] {
221
+ if (typeof content === "string") return [{ type: "text", text: truncateText(content) }];
222
+ if (!Array.isArray(content)) return [];
223
+ const projected: PublicContent[] = [];
224
+ for (const block of content) {
225
+ if (!isRecord(block) || typeof block.type !== "string") continue;
226
+ if (block.type === "text" && typeof block.text === "string") {
227
+ projected.push({ type: "text", text: truncateText(block.text) });
228
+ } else if (block.type === "thinking" && typeof block.thinking === "string") {
229
+ projected.push({ type: "thinking", text: truncateText(block.thinking) });
230
+ } else if (block.type === "image") {
231
+ projected.push({
232
+ type: "image",
233
+ ...(typeof block.mimeType === "string" ? { mimeType: block.mimeType } : {}),
234
+ });
235
+ } else if (
236
+ block.type === "toolCall" &&
237
+ typeof block.id === "string" &&
238
+ typeof block.name === "string"
239
+ ) {
240
+ projected.push({
241
+ type: "toolCall",
242
+ id: block.id,
243
+ name: block.name,
244
+ arguments: boundedJson(block.arguments),
245
+ });
246
+ }
247
+ }
248
+ return projected;
249
+ }
250
+
251
+ function messageId(
252
+ message: Record<string, unknown>,
253
+ timestamp: number | undefined,
254
+ content: unknown,
255
+ ): string {
256
+ if (timestamp !== undefined) return `${message.role}:${timestamp}`;
257
+ if (typeof message.toolCallId === "string") return `tool-result:${message.toolCallId}`;
258
+ const toolCall = Array.isArray(message.content)
259
+ ? message.content.find(
260
+ (block) => isRecord(block) && block.type === "toolCall" && typeof block.id === "string",
261
+ )
262
+ : undefined;
263
+ if (isRecord(toolCall) && typeof toolCall.id === "string") return `assistant:${toolCall.id}`;
264
+ return `${message.role}:live:${hash(JSON.stringify(content))}`;
265
+ }
266
+
267
+ function boundedJson(value: unknown): unknown {
268
+ return sanitizeJson(value, { remaining: MAX_TEXT_BYTES, nodes: 1_000 }, new WeakSet(), 0);
269
+ }
270
+
271
+ function sanitizeJson(
272
+ value: unknown,
273
+ budget: { remaining: number; nodes: number },
274
+ seen: WeakSet<object>,
275
+ depth: number,
276
+ ): unknown {
277
+ if (budget.remaining <= 0 || budget.nodes <= 0) return "[Truncated]";
278
+ budget.nodes -= 1;
279
+ if (value === null || typeof value === "boolean") return value;
280
+ if (typeof value === "string") {
281
+ const text = truncateToBudget(value, budget.remaining);
282
+ budget.remaining -= Buffer.byteLength(text);
283
+ return text;
284
+ }
285
+ if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
286
+ if (typeof value === "bigint") return value.toString();
287
+ if (typeof value !== "object") return String(value ?? "");
288
+ if (seen.has(value)) return "[Circular]";
289
+ if (depth >= 8) return "[Depth limit]";
290
+ seen.add(value);
291
+ if (Array.isArray(value)) {
292
+ const result = value.slice(0, 100).map((item) => sanitizeJson(item, budget, seen, depth + 1));
293
+ if (value.length > 100) result.push(`[${value.length - 100} more items]`);
294
+ return result;
295
+ }
296
+ const result: Record<string, unknown> = {};
297
+ let scannedKeys = 0;
298
+ let ownKeys = 0;
299
+ let omitted = false;
300
+ for (const rawKey in value) {
301
+ scannedKeys += 1;
302
+ if (scannedKeys > 200) {
303
+ omitted = true;
304
+ break;
305
+ }
306
+ if (!Object.hasOwn(value, rawKey)) continue;
307
+ if (ownKeys >= 100 || budget.remaining <= 0 || budget.nodes <= 0) {
308
+ omitted = true;
309
+ break;
310
+ }
311
+ ownKeys += 1;
312
+ const key = truncateToBudget(rawKey, Math.min(500, budget.remaining));
313
+ budget.remaining -= Buffer.byteLength(key);
314
+ try {
315
+ setOwn(
316
+ result,
317
+ key,
318
+ sanitizeJson((value as Record<string, unknown>)[rawKey], budget, seen, depth + 1),
319
+ );
320
+ } catch {
321
+ setOwn(result, key, "[Unreadable]");
322
+ }
323
+ }
324
+ if (omitted) setOwn(result, "…", "More properties omitted");
325
+ return result;
326
+ }
327
+
328
+ function setOwn(target: Record<string, unknown>, key: string, value: unknown): void {
329
+ Object.defineProperty(target, key, {
330
+ value,
331
+ enumerable: true,
332
+ configurable: true,
333
+ writable: true,
334
+ });
335
+ }
336
+
337
+ function truncateToBudget(text: string, maxBytes: number): string {
338
+ if (Buffer.byteLength(text) <= maxBytes) return text;
339
+ let end = Math.min(text.length, maxBytes);
340
+ while (end > 0 && Buffer.byteLength(text.slice(0, end)) > Math.max(0, maxBytes - 16)) end -= 1;
341
+ return `${text.slice(0, end)}… truncated`;
342
+ }
343
+
344
+ function truncateText(text: string): string {
345
+ if (Buffer.byteLength(text) <= MAX_TEXT_BYTES) return text;
346
+ let end = Math.min(text.length, MAX_TEXT_BYTES);
347
+ while (end > 0 && Buffer.byteLength(text.slice(0, end)) > MAX_TEXT_BYTES) end -= 1;
348
+ return `${text.slice(0, end)}\n… output truncated`;
349
+ }
350
+
351
+ function hash(value: string): string {
352
+ let result = 2166136261;
353
+ for (let index = 0; index < value.length; index += 1) {
354
+ result ^= value.charCodeAt(index);
355
+ result = Math.imul(result, 16777619);
356
+ }
357
+ return (result >>> 0).toString(36);
358
+ }
359
+
360
+ function trimOldest<T>(values: Map<string, T>, limit: number): void {
361
+ while (values.size > limit) {
362
+ const oldest = values.keys().next().value;
363
+ if (typeof oldest !== "string") return;
364
+ values.delete(oldest);
365
+ }
366
+ }
367
+
368
+ function clone<T>(value: T): T {
369
+ return structuredClone(value);
370
+ }
371
+
372
+ function isRecord(value: unknown): value is Record<string, unknown> {
373
+ return typeof value === "object" && value !== null && !Array.isArray(value);
374
+ }