From 6f490e0cd37cf1e5818659dfec2e693536713ead Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:03:31 +0530 Subject: [PATCH 1/4] fix(coding-agent): long sessions keep less of tool results in memory - The TUI's tool component kept the whole result it was given, structuredContent too: for bash, the command's output up to 1 MB (the content shows 50 KB), for the rest of the session. It now keeps content, details, isError and durationMs, which is all it draws. - A file read again unchanged gives the string the earlier read gave (detachSharedString: the latest 2M characters of read outputs, by content), so the session holds it once. Live heap after a full collection (macOS, 0.9.5 runtime), long_session.py with every prompt also running a command with 1.3 MB of output, 30 prompts: 91.4 MB -> 27.9 MB. Standard long_session.py (the same four files read every prompt), 60 prompts: 40.6 MB -> 13.6 MB. --- packages/coding-agent/src/core/tools/read.ts | 6 +- .../interactive/components/tool-execution.ts | 9 ++- packages/coding-agent/src/utils/text.ts | 36 ++++++++++ .../test/detach-shared-string.test.ts | 66 +++++++++++++++++++ .../test/tool-execution-component.test.ts | 39 +++++++++++ 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/test/detach-shared-string.test.ts diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 45b9e85c5..2eb8a3044 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -5,7 +5,7 @@ import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; import { type Static, Type } from "typebox"; import { processImage } from "../../utils/image-process.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; -import { detachString } from "../../utils/text.ts"; +import { detachSharedString } from "../../utils/text.ts"; import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; import { resolveReadPathAsync } from "./path-utils.ts"; import { readRenderers } from "./renderers/read.ts"; @@ -236,8 +236,8 @@ export function createReadToolDefinition( outputText = truncation.content; } // The output can be a substring of the whole file's text, which it would keep in memory for as - // long as the session holds the result. - content = [{ type: "text", text: detachString(outputText) }]; + // long as the session holds the result. A file read again unchanged gives the same string. + content = [{ type: "text", text: detachSharedString(outputText) }]; } if (aborted) return; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index a3fe7aea4..b194170da 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -207,7 +207,14 @@ export class ToolExecutionComponent extends Container { }, isPartial = false, ): void { - this.result = result; + // Only what is drawn: a result also carries what other consumers read, such as `structuredContent` (a bash call's + // output up to 1 MB, of which the content shows 50 KB), which the component would otherwise keep for the session. + this.result = { + content: result.content, + isError: result.isError, + ...(result.details === undefined ? {} : { details: result.details }), + ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }), + }; this.isPartial = isPartial; this.updateDisplay(); } diff --git a/packages/coding-agent/src/utils/text.ts b/packages/coding-agent/src/utils/text.ts index fafa8a175..979a65088 100644 --- a/packages/coding-agent/src/utils/text.ts +++ b/packages/coding-agent/src/utils/text.ts @@ -17,3 +17,39 @@ export function stripBom(content: string): string { export function detachString(text: string): string { return Buffer.from(text, "utf-8").toString("utf-8"); } + +/** Below this many characters an output is not worth looking up. */ +const SHARED_MIN_CHARS = 4096; +/** How many characters of outputs `detachSharedString` keeps for later equal ones (oldest dropped first). */ +const SHARED_MAX_CHARS = 2 * 1024 * 1024; +const sharedStrings = new Map(); +let sharedChars = 0; + +/** + * `detachString(text)`, or the copy it returned before for the same content: a file read again unchanged then costs + * the session no second copy. The latest copies are kept, up to 2M characters. + */ +export function detachSharedString(text: string): string { + if (text.length < SHARED_MIN_CHARS) return detachString(text); + const held = sharedStrings.get(text); + if (held !== undefined) { + // (Most recently used last.) + sharedStrings.delete(held); + sharedStrings.set(held, held); + return held; + } + const copy = detachString(text); + sharedStrings.set(copy, copy); + sharedChars += copy.length; + for (const oldest of sharedStrings.keys()) { + if (sharedChars <= SHARED_MAX_CHARS || oldest === copy) break; + sharedStrings.delete(oldest); + sharedChars -= oldest.length; + } + return copy; +} + +/** How many characters `detachSharedString` holds (for tests). */ +export function sharedStringChars(): number { + return sharedChars; +} diff --git a/packages/coding-agent/test/detach-shared-string.test.ts b/packages/coding-agent/test/detach-shared-string.test.ts new file mode 100644 index 000000000..1ef4e0f51 --- /dev/null +++ b/packages/coding-agent/test/detach-shared-string.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, test } from "vitest"; +import { createReadTool } from "../src/core/tools/read.ts"; +import { detachSharedString, sharedStringChars } from "../src/utils/text.ts"; + +/** A string of `length` characters made at run time. */ +function made(seed: string, length: number): string { + return Array.from({ length: Math.ceil(length / seed.length) }, () => seed) + .join("") + .slice(0, length); +} + +describe("detachSharedString", () => { + test("holds equal long content once", () => { + const before = sharedStringChars(); + expect(detachSharedString(made("abc\n", 8192))).toBe(made("abc\n", 8192)); + expect(sharedStringChars()).toBe(before + 8192); + expect(detachSharedString(made("abc\n", 8192))).toBe(made("abc\n", 8192)); + expect(sharedStringChars()).toBe(before + 8192); + expect(detachSharedString(`${made("abc\n", 8191)}x`)).toBe(`${made("abc\n", 8191)}x`); + expect(sharedStringChars()).toBe(before + 2 * 8192); + }); + + test("returns the text unchanged: short, non-ASCII, and a slice of a longer string", () => { + const before = sharedStringChars(); + expect(detachSharedString("short")).toBe("short"); + expect(sharedStringChars()).toBe(before); + const text = made("héllo wörld ✓ 𝄞\n", 10_000); + expect(detachSharedString(text)).toBe(text); + const long = made("0123456789", 100_000); + expect(detachSharedString(long.slice(5, 50_005))).toBe(long.slice(5, 50_005)); + }); + + test("holds at most 2M characters, dropping the oldest", () => { + for (let i = 0; i < 6; i++) detachSharedString(made(`filler ${i}\n`, 600_000)); + expect(sharedStringChars()).toBeLessThanOrEqual(2 * 1024 * 1024); + expect(sharedStringChars()).toBeGreaterThanOrEqual(3 * 600_000); + }); +}); + +describe("read tool", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-read-shared-")); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + test("a file read again gives its current content, held once while unchanged", async () => { + const path = join(dir, "big.txt"); + writeFileSync(path, made("line of the file\n", 20_000)); + const read = createReadTool(dir); + const text = async () => { + const result = await read.execute("call", { path }, undefined, undefined); + const block = result.content[0]; + if (block.type !== "text") throw new Error("expected text"); + return block.text; + }; + const first = await text(); + const held = sharedStringChars(); + expect(await text()).toBe(first); + expect(sharedStringChars()).toBe(held); + writeFileSync(path, made("another line\n", 20_000)); + const changed = await text(); + expect(changed.startsWith("another line\n")).toBe(true); + expect(changed).not.toBe(first); + }); +}); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index f334f7d6b..fc3023856 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -606,6 +606,45 @@ describe("ToolExecutionComponent parity", () => { expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content"); }); + test("keeps only what it draws of a result, not its structuredContent", () => { + const component = new ToolExecutionComponent( + "bash", + "tool-bash-structured", + { command: "seq 1 200000" }, + {}, + createBashToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + const content = [{ type: "text", text: "199999\n200000" }]; + const details = { truncation: undefined }; + const result = { + content, + details, + isError: false, + durationMs: 12, + structuredContent: { output: "1\n".repeat(500_000), truncated: true, exit_code: 0 }, + }; + component.updateResult(result); + const kept = (component as unknown as { result: Record }).result; + expect(kept).toEqual({ content, details, isError: false, durationMs: 12 }); + expect(kept.content).toBe(content); + expect(kept.details).toBe(details); + expect("structuredContent" in kept).toBe(false); + + const plain = new ToolExecutionComponent( + "bash", + "tool-bash-plain", + { command: "seq 1 200000" }, + {}, + createBashToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + plain.updateResult({ content, details, isError: false, durationMs: 12 }); + expect(component.render(120)).toEqual(plain.render(120)); + }); + test("collapses ordinary read results until expanded", () => { const component = new ToolExecutionComponent( "read", From 871cdf60e9e7d0283cf51052b9a27e2dd59641a6 Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:04:41 +0530 Subject: [PATCH 2/4] fix: smaller release archives: no documentation pictures, x86 filter for x86-64 - The docs' PNG files (2.5 MB; the model reads the text, and a terminal shows no picture) are not shipped. - The x86-64 .tar.xz archives go through xz's x86 filter first (call targets made relative): 2% smaller, and every xz since 5.0 unpacks it. Not the ARM64 filter, which xz and libarchive before 5.4 cannot unpack. linux-x64 .tar.xz: 58.68 MB -> 55.17 MB (-6.0%). --- scripts/build-pi.ps1 | 2 ++ scripts/build-pi.sh | 3 +++ scripts/package-release.sh | 6 +++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/build-pi.ps1 b/scripts/build-pi.ps1 index 7e5553974..93cd90e00 100644 --- a/scripts/build-pi.ps1 +++ b/scripts/build-pi.ps1 @@ -192,6 +192,8 @@ function Stage-Assets($dir) { # Pi's documentation and example extensions: Pi points the model (its system prompt) and the user (the first run, /login) at # them, next to the README. Copy-Item -Recurse -Force (Join-Path $Agent 'docs'), (Join-Path $Agent 'examples') $dir + # Not the documentation's pictures (2.5 MB of PNG): the model reads the text, and a terminal shows none of them. + Remove-Item -Recurse -Force (Join-Path $dir 'docs\images') -ErrorAction SilentlyContinue # With Pi-Bolt's own that `pi-bolt doctor` points at (#63). Copy-Item (Join-Path $Root 'docs\PLUGINS.md'), (Join-Path $Root 'docs\ACP.md'), (Join-Path $Root 'docs\TROUBLESHOOTING.md') (Join-Path $dir 'docs') New-Item -ItemType Directory -Force "$dir\theme", "$dir\assets", "$dir\export-html\vendor", "$dir\native\win32\prebuilds" | Out-Null diff --git a/scripts/build-pi.sh b/scripts/build-pi.sh index 6ce4ec8c2..0495c1d86 100755 --- a/scripts/build-pi.sh +++ b/scripts/build-pi.sh @@ -127,6 +127,9 @@ stage_assets() { # Pi's documentation and example extensions: Pi points the model (its system prompt) and the user (the first run, /login) at # them, next to the README. cp -R "$AGENT/docs" "$AGENT/examples" "$dir/" + # Not the documentation's pictures (2.5 MB of PNG, about 4% of the download): the model reads the text, and a terminal + # shows none of them (the one the docs show has its alt text). + rm -rf "$dir/docs/images" # With Pi-Bolt's own that `pi-bolt doctor` points at (#63). cp "$PIBOLT_ROOT/docs/PLUGINS.md" "$PIBOLT_ROOT/docs/ACP.md" "$PIBOLT_ROOT/docs/TROUBLESHOOTING.md" "$dir/docs/" mkdir -p "$dir/theme" "$dir/assets" "$dir/export-html/vendor" "$dir/native/$PIBOLT_OS/prebuilds" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index c2860a9eb..af24e469f 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -82,7 +82,11 @@ for target in "${TARGETS[@]}"; do notices "$dir" log "pi-bolt-$name.tar.gz (Pi $("$build/pi" --version))" "${TAR[@]}" -C "$STAGE" -czf "$DIST/pi-bolt-$name.tar.gz" "pi-bolt-$name" - "${TAR[@]}" -C "$STAGE" -cf - "pi-bolt-$name" | xz -T0 -9e >"$DIST/pi-bolt-$name.tar.xz" # (-T0 writes blocks, which xz -T0 can decompress in parallel) + # (-T0 writes blocks, which xz -T0 can decompress in parallel.) On x86-64 with xz's x86 filter first, which makes the machine + # code's call targets relative and so more alike: 2% smaller, and every xz and liblzma since 2009 unpacks it. Not the ARM64 + # filter: xz and libarchive older than 5.4 (2022; Ubuntu 22.04's, macOS's tar) cannot. + filters=(--lzma2=preset=9e); [ "$PIBOLT_ARCH" = x64 ] && filters=(--x86 "${filters[@]}") + "${TAR[@]}" -C "$STAGE" -cf - "pi-bolt-$name" | xz -T0 "${filters[@]}" >"$DIST/pi-bolt-$name.tar.xz" done if [ -z "$RUNTIME_ONLY" ]; then From 4baa96674c6c1fde905a3d2d28948ef230a20e0d Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:14:04 +0530 Subject: [PATCH 3/4] fix: Windows release archives deflated by 7-Zip where it is installed The same kind of .zip (deflate, no Zip64), which install.ps1, npm/install.cjs and Expand-Archive unpack: pi-bolt.exe 93.9 MB -> 90.7 MB (-3.4%) at -mx=7, about a minute more per archive. Without 7-Zip, .NET's ZipFile as before. --- scripts/package-release.ps1 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 424d7386c..5f87ba5ac 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -93,7 +93,19 @@ function Add-Notices($dir) { Copy-Item (Join-Path $Root 'THIRD_PARTY_NOTICES.md') $dir if (Test-Path (Join-Path $Pi 'LICENSE')) { Copy-Item (Join-Path $Pi 'LICENSE') (Join-Path $dir 'LICENSE.pi') } } +# With 7-Zip where it is installed (GitHub's Windows runners have it): its deflate makes the same kind of .zip, which every reader +# of it unpacks (install.ps1, npm/install.cjs, Expand-Archive), 3.4% smaller than .NET's (-mx=7: pi-bolt.exe 93.9 -> 90.7 MB, +# about a minute more per archive). Without it, .NET's. +$SevenZip = @((Get-Command 7z.exe -ErrorAction SilentlyContinue).Source, (Join-Path $env:ProgramFiles '7-Zip\7z.exe')) | + Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 function New-Zip($dir, $zip) { + if ($SevenZip) { + # (From the folder's parent, so that its entries start with the folder's name, as .NET's do.) + Push-Location (Split-Path $dir) + try { & $SevenZip a -tzip -mx=7 -mtc=off -bso0 -bsp0 $zip (Split-Path $dir -Leaf) } finally { Pop-Location } + if ($LASTEXITCODE -ne 0) { Die "7-Zip could not write $zip" } + return + } [IO.Compression.ZipFile]::CreateFromDirectory($dir, $zip, [IO.Compression.CompressionLevel]::Optimal, $true) } try { From 4bb611fbcc871220529f4336463adbfddbb07a38 Mon Sep 17 00:00:00 2001 From: OpenSec <323596076+opensec-intelligence@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:58:26 +0530 Subject: [PATCH 4/4] fix(coding-agent): keep #69's TUI result fix only (the component-level copy duplicated it) --- .../interactive/components/tool-execution.ts | 9 +---- .../test/tool-execution-component.test.ts | 39 ------------------- 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b194170da..a3fe7aea4 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -207,14 +207,7 @@ export class ToolExecutionComponent extends Container { }, isPartial = false, ): void { - // Only what is drawn: a result also carries what other consumers read, such as `structuredContent` (a bash call's - // output up to 1 MB, of which the content shows 50 KB), which the component would otherwise keep for the session. - this.result = { - content: result.content, - isError: result.isError, - ...(result.details === undefined ? {} : { details: result.details }), - ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }), - }; + this.result = result; this.isPartial = isPartial; this.updateDisplay(); } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 5e0a43608..4804092af 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -637,45 +637,6 @@ describe("ToolExecutionComponent parity", () => { expect(stripAnsi(component.render(width).join("\n"))).toContain("hidden content"); }); - test("keeps only what it draws of a result, not its structuredContent", () => { - const component = new ToolExecutionComponent( - "bash", - "tool-bash-structured", - { command: "seq 1 200000" }, - {}, - createBashToolDefinition(process.cwd()), - createFakeTui(), - process.cwd(), - ); - const content = [{ type: "text", text: "199999\n200000" }]; - const details = { truncation: undefined }; - const result = { - content, - details, - isError: false, - durationMs: 12, - structuredContent: { output: "1\n".repeat(500_000), truncated: true, exit_code: 0 }, - }; - component.updateResult(result); - const kept = (component as unknown as { result: Record }).result; - expect(kept).toEqual({ content, details, isError: false, durationMs: 12 }); - expect(kept.content).toBe(content); - expect(kept.details).toBe(details); - expect("structuredContent" in kept).toBe(false); - - const plain = new ToolExecutionComponent( - "bash", - "tool-bash-plain", - { command: "seq 1 200000" }, - {}, - createBashToolDefinition(process.cwd()), - createFakeTui(), - process.cwd(), - ); - plain.updateResult({ content, details, isError: false, durationMs: 12 }); - expect(component.render(120)).toEqual(plain.render(120)); - }); - test("collapses ordinary read results until expanded", () => { const component = new ToolExecutionComponent( "read",