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
Binary file added icon128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added new-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions runners/extension/chatLocator.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,38 @@ export async function locateChatWidget(tabId, readerCfg, options = {}) {
const clickedLaunchers = [];
let lastErr = "";

// Deterministic fast-path: if the in-frame heuristic already surfaced a
// high-confidence, non-search chat input, use it directly and skip the
// (non-deterministic) LLM planner — this removes most run-to-run variance in
// input detection. Falls through to the planner on any miss (e.g. widget not
// open yet, or only ambiguous/low-score inputs present).
try {
const detFrames = await collectFrames(tabId);
const ranked = detFrames
.filter((f) => f.bestInputSelector && (f.bestInputScore || 0) >= 12)
.sort((a, b) => (b.bestInputScore || 0) - (a.bestInputScore || 0));
for (const f of ranked) {
if (state.OPFOR_STOP) return { ok: false, error: "Run stopped." };
const visible = await actVerifyInputVisible(tabId, f.frameId, f.bestInputSelector);
if (!visible) continue;
const siteSnapshot = detFrames.find((x) => x.frameId === 0)?.snapshot || f.snapshot || "";
return {
ok: true,
plan: {
inputSelector: f.bestInputSelector,
submit: f.bestSendSelector
? { method: "click", buttonSelector: f.bestSendSelector }
: { method: "enter" },
confidence: 0.9,
},
best: { frameId: f.frameId, frameUrl: f.frameUrl || "", snapshot: siteSnapshot },
siteSnapshot,
};
}
} catch {
/* fall through to the LLM planner */
}

const collectAxSnapshots = async () => {
let results;
try {
Expand Down
32 changes: 21 additions & 11 deletions runners/extension/frameDiscovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export async function collectFrames(tabId) {
frameUrl: r.result?.frameUrl,
inputCount: r.result?.inputCount ?? 0,
chatScore: r.result?.chatScore ?? 0,
bestInputSelector: r.result?.bestInputSelector ?? "",
bestInputScore: r.result?.bestInputScore ?? 0,
bestSendSelector: r.result?.bestSendSelector ?? "",
}))
.filter((f) => typeof f.snapshot === "string" && f.snapshot.length > 0);
}
Expand Down Expand Up @@ -57,11 +60,7 @@ export async function pickChatFrame(tabId) {
const frames = await collectFrames(tabId);
if (!frames.length) throw new Error("No frames collected.");

frames.sort((a, b) => {
const boost = embeddedChatBoost(b) - embeddedChatBoost(a);
if (boost !== 0) return boost;
return (b.chatScore || 0) - (a.chatScore || 0) || (b.inputCount || 0) - (a.inputCount || 0);
});
frames.sort(sortByChatRelevance);
return { frames, best: frames[0] };
}

Expand All @@ -74,14 +73,25 @@ export async function pickChatFrameWithRetry(tabId, { maxRetries = 6, intervalMs
for (let i = 0; i < maxRetries; i++) {
await sleep(i === 0 ? 600 : intervalMs);
frames = await collectFrames(tabId);
if (frames.some((f) => (f.chatScore || 0) > 0)) break;
// Wait for the chat INPUT itself, not merely a chat signal. chatScore can go
// positive from container markup while the composer is still mounting (open
// animation, lazy render, input shown only after a greeting) — breaking on
// chatScore alone snapshots too early and misses the input.
if (frames.some((f) => (f.bestInputScore || 0) > 0)) break;
}
if (!frames.length) throw new Error("No frames collected.");

frames.sort((a, b) => {
const boost = embeddedChatBoost(b) - embeddedChatBoost(a);
if (boost !== 0) return boost;
return (b.chatScore || 0) - (a.chatScore || 0) || (b.inputCount || 0) - (a.inputCount || 0);
});
frames.sort(sortByChatRelevance);
return { frames, best: frames[0] };
}

// Shared ranking: embedded-chat URL boost, then a real input, then chat signal.
function sortByChatRelevance(a, b) {
const boost = embeddedChatBoost(b) - embeddedChatBoost(a);
if (boost !== 0) return boost;
return (
(b.bestInputScore || 0) - (a.bestInputScore || 0) ||
(b.chatScore || 0) - (a.chatScore || 0) ||
(b.inputCount || 0) - (a.inputCount || 0)
);
}
36 changes: 33 additions & 3 deletions runners/extension/frame_collect.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,31 @@ function escapeCssValue(v) {
return String(v).replace(/["\\]/g, "\\$&");
}

function nthOfTypeSelector(el) {
const tag = el.tagName.toLowerCase();
const parent = el.parentElement || el.getRootNode?.()?.host?.shadowRoot;
const siblings = parent ? Array.from(parent.children || []) : [];
const sameTag = siblings.filter((c) => c.tagName === el.tagName);
if (sameTag.length <= 1) return tag;
const idx = sameTag.indexOf(el);
// :nth-of-type is 1-based among same-type element siblings — equals our index+1.
return idx >= 0 ? `${tag}:nth-of-type(${idx + 1})` : tag;
}

function selectorFromEl(el) {
if (!(el instanceof Element)) return null;
const testid = el.getAttribute("data-testid");
if (testid) return `[data-testid="${escapeCssValue(testid)}"]`;
const aria = el.getAttribute("aria-label");
if (aria) return `${el.tagName.toLowerCase()}[aria-label="${escapeCssValue(aria)}"]`;
const id = el.getAttribute("id");
if (id) return `#${escapeCssValue(id)}`;
// Skip dynamic ids (e.g. "radix-:r3:") that aren't valid/stable CSS identifiers.
if (id && /^[A-Za-z][\w-]*$/.test(id)) return `#${escapeCssValue(id)}`;
const name = el.getAttribute("name");
if (name) return `${el.tagName.toLowerCase()}[name="${escapeCssValue(name)}"]`;
return el.tagName.toLowerCase();
// No stable attribute — fall back to a positional selector so we don't return a
// bare tag that matches (and later actuates) the wrong element.
return nthOfTypeSelector(el);
}

function getShadowRoot(el) {
Expand Down Expand Up @@ -602,6 +616,15 @@ function collectSanitizedDomSnapshot() {
return true;
});

// Track the single best non-search input deterministically, so callers can use
// the heuristic directly instead of relying on a (non-deterministic) LLM pick.
let bestInput = null;
for (const el of inputs) {
if (looksLikeSiteSearch(el)) continue;
const sc = scoreInput(el);
if (!bestInput || sc > bestInput.score) bestInput = { el, score: sc };
}

lines.push("");
lines.push("CANDIDATE_INPUTS:");
for (const el of inputs.slice(0, 40)) {
Expand Down Expand Up @@ -700,13 +723,16 @@ function collectSanitizedDomSnapshot() {

lines.push("");
lines.push("CANDIDATE_BUTTONS:");
let bestSendSelector = "";
for (const el of buttons) {
const aria = (el.getAttribute?.("aria-label") || "").toLowerCase();
const text = (el.textContent || "").trim().toLowerCase();
const looksLikeSend =
aria.includes("send") || text === "send" || text.includes("send") || text.includes("submit");
const selStr = deepPathSelector(el);
if (looksLikeSend && !bestSendSelector) bestSendSelector = selStr;
lines.push(
`- ${looksLikeSend ? "sendish=1" : "sendish=0"} selector="${deepPathSelector(el)}" ${describeEl(el)}`
`- ${looksLikeSend ? "sendish=1" : "sendish=0"} selector="${selStr}" ${describeEl(el)}`
);
}

Expand Down Expand Up @@ -744,6 +770,10 @@ function collectSanitizedDomSnapshot() {
snapshot: lines.join("\n").slice(0, 60_000),
inputCount: inputs.length,
chatScore,
// Deterministic best chat input + send button for this frame (no LLM needed).
bestInputSelector: bestInput ? deepPathSelector(bestInput.el) : "",
bestInputScore: bestInput ? bestInput.score : 0,
bestSendSelector,
};
}

Expand Down
113 changes: 104 additions & 9 deletions runners/extension/frame_snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,77 @@
"menuitem",
"option",
]);
const MIN_MSG = 20;
const MAX_MSG = 1000;
// Lowered from 20 → 2 so terse bot replies ("Done.", "Order #4521 shipped.")
// are captured instead of being silently dropped. MAX raised to a large sanity
// bound because per-message granularity now comes from block-structure (see
// collectText), not an arbitrary character cap.
const MIN_MSG = 2;
const MAX_MSG = 20000;

// Inline tags never count as block-level message containers — their text is
// part of the surrounding block, so we must NOT recurse past their parent
// (that would lose the plain-text siblings around <strong>, <a>, etc.).
const INLINE_TAGS = new Set([
"a",
"b",
"i",
"em",
"strong",
"span",
"code",
"small",
"sub",
"sup",
"mark",
"u",
"s",
"abbr",
"cite",
"q",
"time",
"label",
"kbd",
"samp",
"var",
"wbr",
"bdi",
"bdo",
"del",
"ins",
"br",
"img",
"svg",
"picture",
]);

function isBlockish(el) {
const tag = el.tagName?.toLowerCase();
if (tag && INLINE_TAGS.has(tag)) return false;
try {
const d = window.getComputedStyle(el).display;
return !(d === "inline" || d === "inline-block" || d === "contents" || d === "none");
} catch {
return true; // assume block when style is unavailable
}
}

// Count block-level child elements that carry their own text. ≥1 means this node
// is a CONTAINER of sub-blocks (transcript, message list, message row) and we
// should recurse into it; 0 means it's a leaf text block (a single message /
// paragraph) safe to capture as one node. This is what stops a short transcript
// from collapsing into a single mega-node (which broke the diff + echo filter).
function blockTextChildren(node) {
let n = 0;
for (const c of node.children || []) {
if (!isBlockish(c)) continue;
const t = (c.textContent || "").replace(/\s+/g, " ").trim();
if (t.length >= MIN_MSG) {
n++;
if (n >= 1) break;
}
}
return n;
}

// ── Text collector (shared by both fast and full paths) ──────────────────────
function collectText(node, depth, out) {
Expand All @@ -46,7 +115,9 @@
const role = (node.getAttribute?.("role") || "").toLowerCase();
if (SKIP_ROLES.has(role)) return;
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
if (text.length >= MIN_MSG && text.length <= MAX_MSG) {
// Capture only LEAF text blocks (no block-level child carries its own text).
// Containers fall through and recurse, so each message becomes its own node.
if (text.length >= MIN_MSG && text.length <= MAX_MSG && blockTextChildren(node) === 0) {
out.push(text);
return;
}
Expand Down Expand Up @@ -75,18 +146,38 @@
};
}

// ── Fast path: reuse selector from previous full scan ────────────────────────
// ── Fast path: reuse the container from a previous full scan ─────────────────
// Prefer a direct reference to the element over re-running querySelector on a
// cached string. String selectors are brittle: hashed/CSS-module class names,
// dynamic ids (radix-:r3:, which is also invalid CSS and throws), and
// first-match ambiguity all make the string resolve to the wrong element or
// none. Holding the node itself is stable until the framework re-renders it.
const cachedEl = globalThis.__OPFOR_CONTAINER_EL__;
if (cachedEl && cachedEl.isConnected) {
try {
const result = snapshotEl(cachedEl, globalThis.__OPFOR_CONTAINER_SEL__ || "");
if (result.nodeCount > 0) return result;
// Element still present but empty — fall through to full scan.
} catch {
/* swallowed */
}
}

// Secondary fast path: the cached selector string (used when the page-world
// element ref was lost, e.g. seeded by the orchestrator across calls).
const cachedSel = globalThis.__OPFOR_CONTAINER_SEL__;
if (cachedSel) {
try {
const el = document.querySelector(cachedSel);
if (el) {
const result = snapshotEl(el, cachedSel);
if (result.nodeCount > 0) return result;
// Container found but empty — fall through to full scan
if (result.nodeCount > 0) {
globalThis.__OPFOR_CONTAINER_EL__ = el;
return result;
}
}
} catch {
/* swallowed */
/* swallowed (invalid/dynamic selector) — fall through to full scan */
}
}

Expand Down Expand Up @@ -216,14 +307,18 @@

const best = candidates[0].el;

// Build selector and cache it for fast subsequent polls
// Cache the element itself for fast subsequent polls (stable across re-renders
// that only change attributes). Keep a best-effort selector string as backup.
let sel = best.tagName.toLowerCase();
if (best.id) {
if (best.id && /^[A-Za-z][\w-]*$/.test(best.id)) {
// Only use #id when it's a static, valid CSS identifier (skip dynamic ids
// like "radix-:r3:" / ":r0:" that change per render and break querySelector).
sel = "#" + best.id;
} else if (typeof best.className === "string" && best.className.trim()) {
const parts = best.className.trim().split(/\s+/).slice(0, 2);
sel = best.tagName.toLowerCase() + "." + parts.join(".");
}
globalThis.__OPFOR_CONTAINER_EL__ = best;
globalThis.__OPFOR_CONTAINER_SEL__ = sel;

const result = snapshotEl(best, sel);
Expand Down
Binary file modified runners/extension/icons/icon128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified runners/extension/icons/icon16.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified runners/extension/icons/icon32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified runners/extension/icons/icon48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified runners/extension/icons/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading