From af9444e2f6a779591c382d792256bbc52599b178 Mon Sep 17 00:00:00 2001 From: Jithin Date: Thu, 13 Aug 2026 18:48:56 +0530 Subject: [PATCH 1/2] fix: recognize typing/thinking and date/status noise in response extractor --- runners/extension/responseExtractor.js | 118 ++++++++++++++++++++----- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/runners/extension/responseExtractor.js b/runners/extension/responseExtractor.js index 1da67e6..cd08264 100644 --- a/runners/extension/responseExtractor.js +++ b/runners/extension/responseExtractor.js @@ -20,6 +20,32 @@ function stripLeadingTimestamp(text) { .trim(); } +// Returns true if `text` is an entire line/node of date/time/read-receipt chrome +// ("Seen at 3:45 PM", "Delivered", "Today", "Aug 13, 2026") rather than reply +// content. Unlike stripLeadingTimestamp (which only strips a timestamp that's +// glued as a PREFIX onto real content on the same line), this catches a whole +// separate node rendered above/below the reply bubble — the shapes vary too +// much to enumerate ("Seen at HH:MM", "Read HH:MM", bare "Delivered", a lone +// date header) so this strips every date/time/status token out and checks +// whether anything is left over, same technique as isTypingIndicator below. +function isDateOrStatusLine(text) { + if (!text) return false; + const t = text.trim(); + if (t.length > 40) return false; // these footers/headers are always short + const rest = t + .toLowerCase() + .replace(/\d{1,2}:\d{2}\s*(?:[ap]\.?m\.?)?/gi, " ") // clock time, e.g. "3:45 pm" + .replace(/\b\d{1,2}(?:st|nd|rd|th)?\b/g, " ") // day-of-month number + .replace(/\b\d{4}\b/g, " ") // year + .replace(/\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b/gi, " ") // weekday + .replace(/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/gi, " ") // month + .replace(/\b(seen|delivered|read|sent|today|yesterday|now|ago|just|at|on|by|am|pm)\b/gi, " ") + .replace(/[^\w\s]/g, " ") // leftover punctuation (commas, dashes, dots) + .replace(/\s+/g, " ") + .trim(); + return rest === ""; +} + // ── Text-node diff ──────────────────────────────────────────────────────────── function diffTextNodes(pre, post) { @@ -201,31 +227,78 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna } } + // Words that only ever show up wrapping a placeholder ("Agent is thinking...", + // "Kai: still generating", "one moment please") — never inside a real reply's + // opening words, since a real reply is addressing the user's actual question. + const PLACEHOLDER_FILLER_WORDS = new Set([ + "agent", + "assistant", + "bot", + "ai", + "support", + "system", + "virtual", + "is", + "are", + "am", + "currently", + "still", + "now", + "just", + "please", + "a", + ]); + const PLACEHOLDER_CORE_WORDS = new Set([ + "typing", + "thinking", + "loading", + "generating", + "responding", + "writing", + "processing", + "analyzing", + "searching", + "fetching", + "working", + "wait", + "waiting", + "moment", + "hold", + "on", + "one", + "sec", + "second", + "it", + ]); + // Returns true if `text` is a transient typing/thinking indicator, not a real reply. + // Dot/ellipsis count and exact wording vary by UI ("Thinking…", "Thinking....", + // "Agent is thinking", "Kai: still generating") so this normalizes rather than + // matching a fixed string list: strip punctuation and known filler words, then + // check whether every remaining word is a known placeholder word AND at least + // one of them is a "core" placeholder verb (so real short replies like "Yes, + // I can help with that" don't get swallowed just for being short). function isTypingIndicator(text) { if (!text) return false; const t = text.trim(); - const tl = t.toLowerCase(); - if (t.length > 120) return false; - // Explicit "is typing" / "is thinking" patterns - if (/\bis\s+typing\b|\bare\s+typing\b/i.test(t)) return true; - // Short placeholder patterns - if ( - /^(typing|thinking|loading|generating|please wait|one moment|working on it)\.{0,3}$/i.test(tl) - ) - return true; - // Dots / ellipsis only + if (t.length > 80) return false; + // Dots / ellipsis only, or a bare streaming cursor if (/^[.…·•\s]+$/.test(t)) return true; - // Streaming cursor if (/^▋?$/.test(t)) return true; - // "Agent/Assistant/Bot is typing…" - if ( - /\b(agent|assistant|bot|support|virtual assistant)\b.*\b(typing|thinking|responding|writing)\b/i.test( - tl - ) - ) - return true; - return false; + + const words = t + .toLowerCase() + .replace(/[…]/g, " ") // ellipsis char + .replace(/[^a-z\s]/g, " ") // strip remaining punctuation/digits + .split(/\s+/) + .filter(Boolean); + if (!words.length) return false; + + const hasCore = words.some((w) => PLACEHOLDER_CORE_WORDS.has(w)); + const allKnown = words.every( + (w) => PLACEHOLDER_CORE_WORDS.has(w) || PLACEHOLDER_FILLER_WORDS.has(w) + ); + return hasCore && allKnown; } // Returns true if `text` looks like the user's own sent message echoed back. @@ -322,7 +395,8 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna // surface as their own nodes — they strip to empty and aren't real reply text. const isTimestampOnly = (l) => stripLeadingTimestamp(l).trim() === ""; const botLines = diffLines.filter( - (l) => !isUserEcho(l) && !isTypingIndicator(l) && !isTimestampOnly(l) + (l) => + !isUserEcho(l) && !isTypingIndicator(l) && !isTimestampOnly(l) && !isDateOrStatusLine(l) ); if (botLines.length > 0) { @@ -344,6 +418,10 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna // Diff contained only user echo and/or typing indicators — advance baseline // past this transient state and keep polling for the real reply. + dbg("extract", "Skipped noise diff, advancing baseline", { + poll, + droppedLines: diffLines.slice(0, 5), + }); baseTextNodes = snap.textNodes; baseFullText = curFullText; baseNodeCount = curNodeCount; From b5bdb14d8a67cba2177387bf8d85b8341c4fe5a4 Mon Sep 17 00:00:00 2001 From: Jithin Date: Fri, 14 Aug 2026 10:37:12 +0530 Subject: [PATCH 2/2] fix: tighten response-extractor noise filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeRabbit review on #245. - isDateOrStatusLine now requires a date/status anchor (clock time, ISO or slashed date, receipt word, weekday) before the strip-and-check, so bare numeric replies ("42", "2026", "$25") are no longer read as a day-of-month or year and dropped. A lone month name needs an accompanying digit. - isTypingIndicator demotes on/one/it to filler so "On it" survives, strips an optional speaker label so "Kai: still generating" matches, and re-anchors the wrapped-indicator regex on an agent subject — a bare "is writing" also matches real replies like "the file is writing to disk". - Noise predicate extracted to isNoiseLine/stripNoise and applied to the polling-expired path too: an animated indicator never goes stable, so that path previously returned "Thinking…" as the reply. - Hoisted the classifiers to module scope; they close over nothing and the two Sets were reallocated on every extraction call. Co-Authored-By: Claude Opus 5 (1M context) --- runners/extension/responseExtractor.js | 215 +++++++++++++++---------- 1 file changed, 130 insertions(+), 85 deletions(-) diff --git a/runners/extension/responseExtractor.js b/runners/extension/responseExtractor.js index cd08264..f9565b8 100644 --- a/runners/extension/responseExtractor.js +++ b/runners/extension/responseExtractor.js @@ -20,18 +20,27 @@ function stripLeadingTimestamp(text) { .trim(); } +// Something that only ever appears in date/time/status chrome, never in a bare +// numeric reply. Required before the strip-and-check below, so a standalone +// "42" / "2026" / "$25" isn't mistaken for a day-of-month or a year. +const DATE_STATUS_ANCHOR = + /\b\d{1,2}:\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{1,2}\/\d{1,2}(?:\/\d{2,4})?\b|\b(?:seen|delivered|read|sent|today|yesterday|ago)\b|\bjust\s+now\b|\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b/i; +const MONTH_WORD = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i; + // Returns true if `text` is an entire line/node of date/time/read-receipt chrome // ("Seen at 3:45 PM", "Delivered", "Today", "Aug 13, 2026") rather than reply // content. Unlike stripLeadingTimestamp (which only strips a timestamp that's // glued as a PREFIX onto real content on the same line), this catches a whole // separate node rendered above/below the reply bubble — the shapes vary too // much to enumerate ("Seen at HH:MM", "Read HH:MM", bare "Delivered", a lone -// date header) so this strips every date/time/status token out and checks -// whether anything is left over, same technique as isTypingIndicator below. +// date header) so this requires a date/status anchor and then checks that +// stripping every date/time/status token leaves nothing behind. function isDateOrStatusLine(text) { if (!text) return false; const t = text.trim(); if (t.length > 40) return false; // these footers/headers are always short + // A bare month name ("May") is also a real word, so it only anchors alongside a number. + if (!DATE_STATUS_ANCHOR.test(t) && !(MONTH_WORD.test(t) && /\d/.test(t))) return false; const rest = t .toLowerCase() .replace(/\d{1,2}:\d{2}\s*(?:[ap]\.?m\.?)?/gi, " ") // clock time, e.g. "3:45 pm" @@ -39,6 +48,7 @@ function isDateOrStatusLine(text) { .replace(/\b\d{4}\b/g, " ") // year .replace(/\b(?:mon|tue|wed|thu|fri|sat|sun)(?:day)?\b/gi, " ") // weekday .replace(/\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/gi, " ") // month + .replace(/\b(?:second|minute|hour|day|week)s?\b/gi, " ") // "3 days ago" .replace(/\b(seen|delivered|read|sent|today|yesterday|now|ago|just|at|on|by|am|pm)\b/gi, " ") .replace(/[^\w\s]/g, " ") // leftover punctuation (commas, dashes, dots) .replace(/\s+/g, " ") @@ -46,6 +56,99 @@ function isDateOrStatusLine(text) { return rest === ""; } +// Words that only ever show up wrapping a placeholder ("Agent is thinking...", +// "Kai: still generating", "one moment please") — never inside a real reply's +// opening words, since a real reply is addressing the user's actual question. +const PLACEHOLDER_FILLER_WORDS = new Set([ + "agent", + "assistant", + "bot", + "ai", + "support", + "system", + "virtual", + "is", + "are", + "am", + "currently", + "still", + "now", + "just", + "please", + "a", + // Structural words that only ever glue a placeholder phrase together + // ("one moment", "hold on", "working on it") — never load-bearing on their + // own, or a real ack like "On it" would be swallowed. + "on", + "one", + "it", + // Objects of a placeholder verb ("Generating response…", "writing a reply"). + "response", + "reply", +]); +const PLACEHOLDER_CORE_WORDS = new Set([ + "typing", + "thinking", + "loading", + "generating", + "responding", + "writing", + "processing", + "analyzing", + "searching", + "fetching", + "working", + "wait", + "waiting", + "moment", + "hold", + "sec", + "second", +]); + +// Returns true if `text` is a transient typing/thinking indicator, not a real reply. +// Dot/ellipsis count and exact wording vary by UI ("Thinking…", "Thinking....", +// "Agent is thinking", "Kai: still generating") so this normalizes rather than +// matching a fixed string list: strip punctuation and known filler words, then +// check whether every remaining word is a known placeholder word AND at least +// one of them is a "core" placeholder verb (so real short replies like "Yes, +// I can help with that" don't get swallowed just for being short). +function isTypingIndicator(text) { + if (!text) return false; + const t = text.trim(); + if (t.length > 120) return false; + // Dots / ellipsis only, or a bare streaming cursor + if (/^[.…·•\s]+$/.test(t)) return true; + if (/^▋?$/.test(t)) return true; + // Indicator phrasing that trails words the vocabulary check below can't know + // ("…thinking about your question"). Both forms are anchored on a subject + // that can only be the UI talking about itself — a bare "is writing" would + // also match a real reply like "the file is writing to disk". + if (/\b(?:is|are)\s+typing\b/i.test(t)) return true; + if ( + /\b(?:agent|assistant|bot|support|system)\b.*\b(?:typing|thinking|responding|writing|generating)\b/i.test( + t + ) + ) + return true; + + const words = t + // Drop a speaker label the UI glued on ("Kai: still generating"). + .replace(/^[a-z][a-z .'’-]{0,23}:\s+/i, "") + .toLowerCase() + .replace(/[…]/g, " ") // ellipsis char + .replace(/[^a-z\s]/g, " ") // strip remaining punctuation/digits + .split(/\s+/) + .filter(Boolean); + if (!words.length) return false; + + const hasCore = words.some((w) => PLACEHOLDER_CORE_WORDS.has(w)); + const allKnown = words.every( + (w) => PLACEHOLDER_CORE_WORDS.has(w) || PLACEHOLDER_FILLER_WORDS.has(w) + ); + return hasCore && allKnown; +} + // ── Text-node diff ──────────────────────────────────────────────────────────── function diffTextNodes(pre, post) { @@ -227,80 +330,6 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna } } - // Words that only ever show up wrapping a placeholder ("Agent is thinking...", - // "Kai: still generating", "one moment please") — never inside a real reply's - // opening words, since a real reply is addressing the user's actual question. - const PLACEHOLDER_FILLER_WORDS = new Set([ - "agent", - "assistant", - "bot", - "ai", - "support", - "system", - "virtual", - "is", - "are", - "am", - "currently", - "still", - "now", - "just", - "please", - "a", - ]); - const PLACEHOLDER_CORE_WORDS = new Set([ - "typing", - "thinking", - "loading", - "generating", - "responding", - "writing", - "processing", - "analyzing", - "searching", - "fetching", - "working", - "wait", - "waiting", - "moment", - "hold", - "on", - "one", - "sec", - "second", - "it", - ]); - - // Returns true if `text` is a transient typing/thinking indicator, not a real reply. - // Dot/ellipsis count and exact wording vary by UI ("Thinking…", "Thinking....", - // "Agent is thinking", "Kai: still generating") so this normalizes rather than - // matching a fixed string list: strip punctuation and known filler words, then - // check whether every remaining word is a known placeholder word AND at least - // one of them is a "core" placeholder verb (so real short replies like "Yes, - // I can help with that" don't get swallowed just for being short). - function isTypingIndicator(text) { - if (!text) return false; - const t = text.trim(); - if (t.length > 80) return false; - // Dots / ellipsis only, or a bare streaming cursor - if (/^[.…·•\s]+$/.test(t)) return true; - if (/^▋?$/.test(t)) return true; - - const words = t - .toLowerCase() - .replace(/[…]/g, " ") // ellipsis char - .replace(/[^a-z\s]/g, " ") // strip remaining punctuation/digits - .split(/\s+/) - .filter(Boolean); - if (!words.length) return false; - - const hasCore = words.some((w) => PLACEHOLDER_CORE_WORDS.has(w)); - const allKnown = words.every( - (w) => PLACEHOLDER_CORE_WORDS.has(w) || PLACEHOLDER_FILLER_WORDS.has(w) - ); - return hasCore && allKnown; - } - // Returns true if `text` looks like the user's own sent message echoed back. function isUserEcho(text) { if (!lastUserText || !text) return false; @@ -314,6 +343,20 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna return false; } + // Every kind of non-reply line, in one place so both return paths below + // (stable diff, and the partial result after polling expires) drop the same + // things. Pure-timestamp lines strip to empty via stripLeadingTimestamp. + const isNoiseLine = (l) => + isUserEcho(l) || + isTypingIndicator(l) || + stripLeadingTimestamp(l).trim() === "" || + isDateOrStatusLine(l); + const stripNoise = (raw) => + raw + .split("\n") + .filter((l) => l.trim()) + .filter((l) => !isNoiseLine(l)); + async function pollFrame() { const results = await chrome.scripting.executeScript({ target: { tabId, frameIds: [targetFrameId] }, @@ -391,13 +434,7 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna if (stableCount >= neededStable) { const { text: rawDiff } = diffTextNodes(baseTextNodes, snap.textNodes); const diffLines = rawDiff.split("\n").filter((l) => l.trim()); - // Drop pure-timestamp lines (e.g. message-bubble "3:45 PM" footers) that now - // surface as their own nodes — they strip to empty and aren't real reply text. - const isTimestampOnly = (l) => stripLeadingTimestamp(l).trim() === ""; - const botLines = diffLines.filter( - (l) => - !isUserEcho(l) && !isTypingIndicator(l) && !isTimestampOnly(l) && !isDateOrStatusLine(l) - ); + const botLines = stripNoise(rawDiff); if (botLines.length > 0) { dbg("extract", "Response extracted successfully", { @@ -443,10 +480,18 @@ export async function extractResponse(tabId, frameId, lastUserText = "", prevSna }); if (bestSnap) { const { text } = diffTextNodes(baseTextNodes, bestSnap.textNodes); - if (text.trim()) { + // An animated indicator never goes stable, so the loop above never got to + // filter it — without this the timeout path hands back "Thinking…" as the reply. + const partialLines = stripNoise(text); + if (!partialLines.length && text.trim()) { + dbg("extract", "Partial result was all noise, discarding", { + droppedLines: text.split("\n").filter(Boolean).slice(0, 5), + }); + } + if (partialLines.length) { return { ok: true, - text: text.trim(), + text: partialLines.join("\n"), typing: false, intermediate: false, counts: { total: bestSnap.nodeCount, botCount: bestSnap.nodeCount, userCount: 0 },