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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

### Added
- `--snapshot-compact` flag for token-efficient LLM consumption - applies four transforms: link collapsing (merges link + /url child into `link "Title" -> /path`), heading inlining (merges heading with single link child), decorative image removal (strips img nodes with empty or single-char alt text), and duplicate URL dedup (removes second occurrence at same depth scope). Applied after `--snapshot-depth` and before `--snapshot-collapse` in the pipeline
- `--snapshot-max-lines <N>` flag to truncate snapshot output to a maximum number of lines, with a `... (K more lines)` marker when lines are omitted
- `--snapshot-collapse` flag to collapse repeated consecutive siblings of the same ARIA type - keeps first 2 with subtrees, replaces the rest with `... (K more <type>)` markers. Works recursively on nested structures
- `--snapshot-text-only` flag to strip structural container nodes (list, listitem, group, region, main, form, table, row, grid, generic, etc.) and keep only content-bearing nodes. Labeled structural nodes are preserved. Indentation is re-compressed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ This eliminates the common click-snapshot-check loop that wastes agent turns on
| `--snapshot-depth <N>` | Any action with snapshot | Limit ARIA tree depth (e.g. 3 for top 3 levels) |
| `--snapshot-selector <sel>` | Any action with snapshot | Scope snapshot to a DOM subtree |
| `--snapshot-max-lines <N>` | Any action with snapshot | Truncate snapshot to N lines |
| `--snapshot-compact` | Any action with snapshot | Compact format: collapse links, inline headings, remove decorative images, dedup URLs |
| `--snapshot-collapse` | Any action with snapshot | Collapse repeated siblings (keep first 2, summarize rest) |
| `--snapshot-text-only` | Any action with snapshot | Strip structural nodes, keep content only |
| `--max-field-length <N>` | `extract` | Max characters per field (default: 500, max: 2000) |
Expand Down
1 change: 1 addition & 0 deletions commands/web-ctl.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> snapshot --snapshot-depth 3
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> goto <url> --snapshot-selector "css=nav"
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> click <sel> --no-snapshot
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> snapshot --snapshot-collapse
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> snapshot --snapshot-compact
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> snapshot --snapshot-text-only --snapshot-max-lines 50
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> goto <url> --snapshot-collapse --snapshot-depth 4

Expand Down
225 changes: 224 additions & 1 deletion scripts/web-ctl.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const ALLOWED_SCHEMES = /^https?:\/\//i;
const BOOLEAN_FLAGS = new Set([
'--allow-evaluate', '--no-snapshot', '--wait-stable', '--vnc',
'--exact', '--accept', '--submit', '--dismiss', '--auto',
'--snapshot-collapse', '--snapshot-text-only',
'--snapshot-collapse', '--snapshot-text-only', '--snapshot-compact',
]);

function validateSessionName(name) {
Expand Down Expand Up @@ -94,6 +94,7 @@ function resolveSelector(page, selector) {
* @param {boolean} [opts.noSnapshot] - Return null to omit snapshot entirely
* @param {string} [opts.snapshotSelector] - Scope snapshot to a DOM subtree
* @param {number} [opts.snapshotDepth] - Limit ARIA tree depth
* @param {boolean} [opts.snapshotCompact] - Compact format for token efficiency
* @param {boolean} [opts.snapshotCollapse] - Collapse repeated siblings
* @param {boolean} [opts.snapshotTextOnly] - Strip structural nodes, keep content
* @param {number} [opts.snapshotMaxLines] - Truncate to N lines
Expand All @@ -107,6 +108,7 @@ async function getSnapshot(page, opts = {}) {
const raw = await root.ariaSnapshot();
let result = raw;
if (opts.snapshotDepth) result = trimByDepth(result, opts.snapshotDepth);
if (opts.snapshotCompact) result = compactFormat(result);
if (opts.snapshotCollapse) result = collapseRepeated(result);
if (opts.snapshotTextOnly) result = textOnly(result);
if (opts.snapshotMaxLines) result = trimByLines(result, opts.snapshotMaxLines);
Expand Down Expand Up @@ -155,6 +157,224 @@ function trimByDepth(snapshot, maxDepth) {
return result.join('\n');
}

/**
* Compact snapshot for token-efficient LLM consumption.
* Applies four transforms in sequence:
* 1. Link collapsing: merges link + child /url into a single line
* 2. Heading inlining: merges heading with single link child
* 3. Decorative image removal: strips img nodes with empty or single-char alt text
* 4. Duplicate URL dedup: removes second occurrence of the same URL at the same depth scope
*
* @param {string} snapshot - ARIA snapshot text
* @returns {string} Compacted snapshot
*/
function compactFormat(snapshot) {
if (snapshot == null) return snapshot;
if (typeof snapshot === 'string' && snapshot.startsWith('(')) return snapshot;

let lines = snapshot.split('\n');

// --- Pass 1: Link collapsing ---
// Pattern: "- link "Title":" followed by child "- /url: /path"
// Collapsed to: "- link "Title" -> /path"
const linkCollapsed = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
let spaces = 0;
while (spaces < line.length && line[spaces] === ' ') spaces++;
const content = line.slice(spaces);

// Check if this is a link line with a colon suffix (has children)
const linkMatch = content.match(/^- link "([^"]+)":/);
if (linkMatch) {
const parentDepth = Math.floor(spaces / 2);

// Collect children
const children = [];
let j = i + 1;
while (j < lines.length) {
let cs = 0;
while (cs < lines[j].length && lines[j][cs] === ' ') cs++;
if (Math.floor(cs / 2) > parentDepth) {
children.push({ index: j, line: lines[j], depth: Math.floor(cs / 2) });
j++;
} else {
break;
}
}

// Find /url: child among direct children (depth === parentDepth + 1)
const urlChildIdx = children.findIndex(c =>
c.depth === parentDepth + 1 && c.line.trim().match(/^- \/url: (\S+)/)
);

if (urlChildIdx !== -1) {
const urlMatch = children[urlChildIdx].line.trim().match(/^- \/url: (\S+)/);
const url = urlMatch[1];
const otherChildren = children.filter((_, idx) => idx !== urlChildIdx);

if (otherChildren.length === 0) {
// Simple case: link + /url only -> merge to single line
linkCollapsed.push(`${' '.repeat(spaces)}- link "${linkMatch[1]}" -> ${url}`);
} else {
// Link has extra children beyond /url: append -> url to parent, keep other children
linkCollapsed.push(`${' '.repeat(spaces)}- link "${linkMatch[1]}" -> ${url}:`);
for (const child of otherChildren) {
linkCollapsed.push(child.line);
}
}
i = j;
continue;
}
}

linkCollapsed.push(line);
i++;
}
lines = linkCollapsed;

// --- Pass 2: Heading inlining ---
// Pattern: heading with [level=N] and single link child -> merged
const headingInlined = [];
i = 0;
while (i < lines.length) {
const line = lines[i];
let spaces = 0;
while (spaces < line.length && line[spaces] === ' ') spaces++;
const content = line.slice(spaces);

const headingMatch = content.match(/^- heading "([^"]+)" \[level=(\d+)\]:/);
if (headingMatch) {
const parentDepth = Math.floor(spaces / 2);

// Collect direct children
const children = [];
let j = i + 1;
while (j < lines.length) {
let cs = 0;
while (cs < lines[j].length && lines[j][cs] === ' ') cs++;
if (Math.floor(cs / 2) > parentDepth) {
children.push({ index: j, line: lines[j], depth: Math.floor(cs / 2) });
j++;
} else {
break;
}
}

// Check for single direct child that is a link (possibly with -> url already)
const directChildren = children.filter(c => c.depth === parentDepth + 1);
if (directChildren.length === 1) {
const childContent = directChildren[0].line.trim();
const linkArrowMatch = childContent.match(/^- link "([^"]+)" -> (\S+)$/);
if (linkArrowMatch) {
// heading + link -> url: merge into one line
headingInlined.push(`${' '.repeat(spaces)}- heading [h${headingMatch[2]}] "${headingMatch[1]}" -> ${linkArrowMatch[2]}`);
i = j;
continue;
}
const linkPlainMatch = childContent.match(/^- link "([^"]+)"$/);
if (linkPlainMatch) {
// heading + plain link (no url): inline
headingInlined.push(`${' '.repeat(spaces)}- heading [h${headingMatch[2]}] "${headingMatch[1]}"`);
i = j;
continue;
}
}
}

headingInlined.push(line);
i++;
}
lines = headingInlined;

// --- Pass 3: Decorative image removal ---
// Remove img nodes with empty name or single-char alt text
const imagesFiltered = [];
i = 0;
while (i < lines.length) {
const line = lines[i];
let spaces = 0;
while (spaces < line.length && line[spaces] === ' ') spaces++;
const content = line.slice(spaces);

const imgMatch = content.match(/^- img(?:\s+"([^"]*)")?/);
if (imgMatch) {
const altText = imgMatch[1] || '';
if (altText.length <= 1) {
// Decorative image - skip it and its children
const parentDepth = Math.floor(spaces / 2);
let j = i + 1;
while (j < lines.length) {
let cs = 0;
while (cs < lines[j].length && lines[j][cs] === ' ') cs++;
if (Math.floor(cs / 2) > parentDepth) {
j++;
} else {
break;
}
}
i = j;
continue;
}
}

imagesFiltered.push(line);
i++;
}
lines = imagesFiltered;

// --- Pass 4: Duplicate URL dedup ---
// Track seen URLs per depth scope; second occurrence removed
// Reset when depth decreases
const deduped = [];
const seenUrls = new Map(); // depth -> Set of URLs
let prevDepth = -1;

for (i = 0; i < lines.length; i++) {
const line = lines[i];
let spaces = 0;
while (spaces < line.length && line[spaces] === ' ') spaces++;
const depth = Math.floor(spaces / 2);

// When depth decreases, clear URL tracking for deeper levels
if (depth < prevDepth) {
for (const [d] of seenUrls) {
if (d > depth) seenUrls.delete(d);
}
}
prevDepth = depth;

// Extract URL from lines with "-> url" pattern
const urlArrowMatch = line.match(/ -> (\/\S+|https?:\/\/\S+)/);
if (urlArrowMatch) {
const url = urlArrowMatch[1];
if (!seenUrls.has(depth)) seenUrls.set(depth, new Set());
const depthSet = seenUrls.get(depth);
if (depthSet.has(url)) {
// Duplicate - skip this line and its children
let j = i + 1;
while (j < lines.length) {
let cs = 0;
while (cs < lines[j].length && lines[j][cs] === ' ') cs++;
if (Math.floor(cs / 2) > depth) {
j++;
} else {
break;
}
}
i = j - 1; // -1 because for loop increments
continue;
}
depthSet.add(url);
}

deduped.push(line);
}

return deduped.join('\n');
}

/**
* Truncate snapshot output to a maximum number of lines.
* Appends a marker indicating how many lines were omitted.
Expand Down Expand Up @@ -946,6 +1166,8 @@ Snapshot options (apply to any action that returns a snapshot):
--snapshot-selector <sel> Scope snapshot to a DOM subtree
--no-snapshot Omit snapshot from output entirely
--snapshot-max-lines <N> Truncate snapshot to N lines
--snapshot-compact Compact format: collapse links, inline headings,
remove decorative images, dedup URLs
--snapshot-collapse Collapse repeated siblings (show first 2)
--snapshot-text-only Strip structural nodes, keep content only

Expand All @@ -969,6 +1191,7 @@ Examples:
web-ctl run github goto "https://github.com" --snapshot-selector "css=nav"
web-ctl run github click "#btn" --no-snapshot
web-ctl run github snapshot --snapshot-collapse
web-ctl run github snapshot --snapshot-compact
web-ctl run github snapshot --snapshot-text-only --snapshot-max-lines 50
web-ctl session end github`);
}
Expand Down
16 changes: 16 additions & 0 deletions skills/web-browse/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,22 @@ node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> goto <url> --snapshot-max-l

Hard-caps the snapshot output to N lines. A marker like `... (42 more lines)` is appended when lines are omitted. Applied after all other snapshot transforms, so it acts as a final safety net. Max value: 10000.

### --snapshot-compact - Token-Efficient Compact Format

```bash
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> snapshot --snapshot-compact
node ${PLUGIN_ROOT}/scripts/web-ctl.js run <session> goto <url> --snapshot-compact
```

Applies four token-saving transforms in sequence:

1. **Link collapsing** - Merges `link "Title":` with its `/url: /path` child into `link "Title" -> /path`
2. **Heading inlining** - Merges `heading "Title" [level=N]:` with a single link child into `heading [hN] "Title" -> /path`
3. **Decorative image removal** - Strips `img` nodes with empty or single-character alt text (decorative icons, spacers)
4. **Duplicate URL dedup** - Removes the second occurrence of the same URL within the same depth scope

Combines well with `--snapshot-collapse` and `--snapshot-text-only` for maximum reduction. Applied after `--snapshot-depth` and before `--snapshot-collapse` in the pipeline.

### --snapshot-collapse - Collapse Repeated Siblings

```bash
Expand Down
Loading
Loading