Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/coding-agent/src/core/tools/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
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";
Expand Down Expand Up @@ -415,8 +415,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;
Expand Down
36 changes: 36 additions & 0 deletions packages/coding-agent/src/utils/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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;
}
66 changes: 66 additions & 0 deletions packages/coding-agent/test/detach-shared-string.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions scripts/build-pi.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/build-pi.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions scripts/package-release.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion scripts/package-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading