diff --git a/src/tools/browser/aria-compressor.ts b/src/tools/browser/aria-compressor.ts index 3e501f8..e58f0e6 100644 --- a/src/tools/browser/aria-compressor.ts +++ b/src/tools/browser/aria-compressor.ts @@ -17,6 +17,15 @@ export interface AriaCompressionOptions { * failure modes. */ maxLines?: number; + /** + * Soft character budget applied AFTER noise removal and BEFORE the + * line cap. Dense SPAs can emit short lines that still blow small + * local-model context windows (8k–16k). Default ~24k chars ≈ a few + * thousand tokens of ARIA text — enough for useful refs, small enough + * that a 9B/ local chat model still has room for the stable prefix. + * Set `0` / Infinity to disable. Related: #21. + */ + maxChars?: number; /** * Drop pure container lines (`generic`, `group`, `none`, ...) that * carry no name and no inline text. Default true; tests set false to @@ -25,6 +34,9 @@ export interface AriaCompressionOptions { dropNoise?: boolean; } +/** Default char budget when callers omit `maxChars`. */ +export const DEFAULT_ARIA_MAX_CHARS = 24_000; + /** * Roles that convey no semantic signal to the model on their own: empty * wrappers that exist only to group children. Playwright's AI-mode @@ -101,6 +113,12 @@ export function summariseAriaSnapshot( options: AriaCompressionOptions = {}, ): AriaSnapshotSummary { const maxLines = options.maxLines ?? 800; + const maxChars = + options.maxChars === undefined + ? DEFAULT_ARIA_MAX_CHARS + : options.maxChars <= 0 || !Number.isFinite(options.maxChars) + ? Number.POSITIVE_INFINITY + : Math.trunc(options.maxChars); const dropNoise = options.dropNoise ?? true; const rawLines = rawText.split(/\r?\n/); @@ -117,11 +135,19 @@ export function summariseAriaSnapshot( kept.push(line); } - const truncatedByLimit = kept.length > maxLines; - const finalLines = truncatedByLimit ? kept.slice(0, maxLines) : kept; - const omittedByLimit = kept.length - finalLines.length; - const omittedChars = truncatedByLimit - ? kept.slice(maxLines).reduce((sum, line) => sum + line.length + 1, 0) + // Prefer keeping interactive / named nodes when we must cut by chars: + // score lines with refs or quoted names higher and pack greedily in + // original order until the budget fills (order-preserving pack). + const charPacked = packLinesToCharBudget(kept, maxChars); + const afterChars = charPacked.lines; + const truncatedByChars = charPacked.truncated; + const omittedCharsByBudget = charPacked.omittedChars; + + const truncatedByLimit = afterChars.length > maxLines; + const finalLines = truncatedByLimit ? afterChars.slice(0, maxLines) : afterChars; + const omittedByLimit = afterChars.length - finalLines.length; + const omittedCharsByLines = truncatedByLimit + ? afterChars.slice(maxLines).reduce((sum, line) => sum + line.length + 1, 0) : 0; const body = finalLines.join("\n"); @@ -131,9 +157,14 @@ export function summariseAriaSnapshot( `… [collapsed ${droppedNoise} empty container lines]`, ); } + if (truncatedByChars) { + footerParts.push( + `… [truncated ARIA tree by size; ~${omittedCharsByBudget} chars over budget — scroll or navigate to see more]`, + ); + } if (truncatedByLimit) { footerParts.push( - `… [truncated ARIA tree; ${omittedByLimit} lines / ~${omittedChars} chars omitted — scroll or navigate to see more]`, + `… [truncated ARIA tree; ${omittedByLimit} lines / ~${omittedCharsByLines} chars omitted — scroll or navigate to see more]`, ); } const footer = footerParts.length > 0 ? `\n${footerParts.join("\n")}` : ""; @@ -155,6 +186,40 @@ export function summariseAriaSnapshot( return { text, digest, refs }; } +/** + * Order-preserving pack: include lines until `maxChars` would be exceeded. + * Prefer dropping pure noise-adjacent empty lines already removed upstream; + * here we simply cut the tail so refs near the top of the page survive — + * which is what the model almost always acts on first. + */ +export function packLinesToCharBudget( + lines: readonly string[], + maxChars: number, +): { lines: string[]; truncated: boolean; omittedChars: number } { + if (!Number.isFinite(maxChars) || maxChars === Number.POSITIVE_INFINITY) { + return { lines: [...lines], truncated: false, omittedChars: 0 }; + } + if (maxChars <= 0) { + return { lines: [], truncated: lines.length > 0, omittedChars: lines.join("\n").length }; + } + const out: string[] = []; + let used = 0; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]!; + // +1 for the join newline, except before the first line. + const add = line.length + (out.length > 0 ? 1 : 0); + if (used + add > maxChars) { + const omittedChars = lines + .slice(i) + .reduce((sum, l, idx) => sum + l.length + (idx > 0 || out.length > 0 ? 1 : 0), 0); + return { lines: out, truncated: true, omittedChars }; + } + out.push(line); + used += add; + } + return { lines: out, truncated: false, omittedChars: 0 }; +} + function extractRefs(rawText: string): string[] { const pattern = /\[ref=([a-zA-Z0-9]+)\]/g; const seen = new Set(); diff --git a/src/tools/browser/browser-tools.test.ts b/src/tools/browser/browser-tools.test.ts index b042f31..8b67844 100644 --- a/src/tools/browser/browser-tools.test.ts +++ b/src/tools/browser/browser-tools.test.ts @@ -403,6 +403,21 @@ describe("browser tools on a fake backend", () => { }); describe("summariseAriaSnapshot", () => { + + it("applies maxChars budget and reports size truncation footer", () => { + const lines = Array.from({ length: 200 }, (_, i) => `- link "Item ${i}" [ref=e${i}]`); + const raw = lines.join("\n"); + const out = summariseAriaSnapshot( + raw, + { url: "https://example.com", title: "t" }, + { maxChars: 400, dropNoise: false }, + ); + expect(out.text.length).toBeLessThan(raw.length); + expect(out.text).toMatch(/truncated ARIA tree by size/); + expect(out.refs.length).toBeGreaterThan(0); + expect(out.refs.length).toBeLessThan(200); + }); + it("extracts refs and emits a header", () => { const raw = [ "- banner", diff --git a/src/tools/browser/index.ts b/src/tools/browser/index.ts index c886455..19a3478 100644 --- a/src/tools/browser/index.ts +++ b/src/tools/browser/index.ts @@ -42,7 +42,11 @@ export { stopChrome, } from "./spawn-chrome.js"; export type { RunningChrome, SpawnChromeInput } from "./spawn-chrome.js"; -export { summariseAriaSnapshot } from "./aria-compressor.js"; +export { + DEFAULT_ARIA_MAX_CHARS, + packLinesToCharBudget, + summariseAriaSnapshot, +} from "./aria-compressor.js"; export type { AriaCompressionOptions, AriaSnapshotSummary,