From 7e57a750aab2f9251263ed3f60f5f5fac03c9164 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 19:20:18 +0200 Subject: [PATCH 1/5] Decisions: a "nothing new" line that goes on without asking is still nothing The waiting-for-you section counted as empty only when its whole first line was short. "nothing new from this run. You may want to ..." ran past that and became a task titled "nothing new from this run.". Its first sentence decides now, unless a later one asks or waits. --- packages/core/src/runner/openDecision.ts | 7 +++++-- packages/core/test/open-decision.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runner/openDecision.ts b/packages/core/src/runner/openDecision.ts index bf145f9e..ef8b5a39 100644 --- a/packages/core/src/runner/openDecision.ts +++ b/packages/core/src/runner/openDecision.ts @@ -103,7 +103,7 @@ const CHOICE = /\b(?:or|oder)\b/i; /** The section says there is nothing to decide. */ const NOTHING = - /^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:open |pending )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i; + /^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i; interface Heading { line: number; @@ -185,7 +185,10 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul const next = headings.find((o) => o.line > h.line && o.level <= h.level); const section = [h.rest, ...lines.slice(h.line + 1, next?.line ?? lines.length)].join("\n").trim(); const said = plain(section.split("\n").find((l) => l.trim()) ?? ""); - if (!section || NOTHING.test(said)) { + // "nothing new from this run. You may want to …" goes on without asking: still nothing. + const all = sentences(section); + const asks = all.some((s) => isQuestion(s) || WAITS.some((re) => re.test(s))); + if (!section || NOTHING.test(said) || (NOTHING.test(all[0] ?? "") && !asks)) { saidNothing = true; continue; } diff --git a/packages/core/test/open-decision.test.ts b/packages/core/test/open-decision.test.ts index d8d864ac..599e2a0b 100644 --- a/packages/core/test/open-decision.test.ts +++ b/packages/core/test/open-decision.test.ts @@ -49,6 +49,19 @@ describe("a decision left open in an answer", () => { test("a section that says there is nothing to decide", () => { expect(detectOpenDecision("Done.\n\n## Decisions for you\n\nNone.\n\n## Next\n\nShould I deploy it?")).toBeNull(); expect(detectOpenDecision("Fertig.\n\n**Offene Entscheidungen:** keine")).toBeNull(); + expect( + detectOpenDecision( + "| Ticket | Status |\n|---|---|\n| Demo shop | complete |\n\n**Waiting for you:** nothing new from this run. You will probably want to settle the pricing before the next calls, but nobody asked you directly, so I didn't create a task for it.\n\nI recorded all 10 tickets in `workspace/state.json`.", + ), + ).toBeNull(); + expect(detectOpenDecision("Shift done.\n\n4. **Needed from you:** none. I took back H-74, which was created from my \"nothing\" line.")).toBeNull(); + expect(detectOpenDecision("No replies.\n\n**Needed from Dana or Sam:** nothing new. Still open from Sam: two addresses and his answers on two leads.", "Dana")).toBeNull(); + }); + + test("a section that starts with none but still asks", () => { + expect(detectOpenDecision("Checked the inbox.\n\n**Waiting for you:** None of the leads replied. Should I send the follow-ups now or on Monday?")?.title).toBe( + "Should I send the follow-ups now or on Monday?", + ); }); test("a closing question to the human", () => { From 530d39d116ad4aa3351a41df7f8cd392e0d69f98 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 19:20:18 +0200 Subject: [PATCH 2/5] Missing logins: no entry from a sentence that passes on a filed report, dismiss the old ones without a site A sentence like "its login failed; it reported that, and it's already on your list" relays another report and files nothing. Open entries that older builds filed as "Unknown service" without an address are dismissed once (migration 98): nothing can be added for them. --- packages/core/src/db/migrations.ts | 10 ++++++++++ packages/core/src/runner/stream.ts | 6 +++++- packages/core/test/missing-logins.test.ts | 14 +++++++++++++- packages/core/test/stream.test.ts | 3 +++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 3a989f5a..3d2df1b9 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1596,6 +1596,16 @@ SELECT 'aud_' || lower(hex(randomblob(8))), strftime('%Y-%m-%dT%H:%M:%fZ', 'now' FROM settings WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5'; UPDATE settings SET value = json_set(value, '$.fallbackModel', 'claude-opus-5') WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5'; +`, + }, + { + id: 98, + name: "dismiss_unknown_missing_logins", + sql: /* sql */ ` +-- Older builds filed a missing login from any answer that mentioned a failed login, without a page it was about: no +-- site, no address, mostly a quoted error or another agent's report. Nothing can be added for those. +UPDATE missing_logins SET status = 'dismissed', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE status = 'open' AND service = 'Unknown service' AND url = ''; `, }, ]; diff --git a/packages/core/src/runner/stream.ts b/packages/core/src/runner/stream.ts index 67ef5642..62c00194 100644 --- a/packages/core/src/runner/stream.ts +++ b/packages/core/src/runner/stream.ts @@ -836,6 +836,10 @@ const LOGIN_FAILURE_PATTERNS: RegExp[] = [ /\b(?:no|missing)\s+(?:2fa|totp|mfa|two[- ]factor|authenticator)\b/i, ]; +/** The sentence passes on a login problem that is already reported ("it reported that, and it's already on your list"). */ +const ALREADY_REPORTED = + /\b(?:already\s+(?:reported|flagged|filed|on\s+(?:your|the)\s+(?:list|task list|inbox))|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:it|they|he|she|which)\s+reported\s+(?:that|it|this)|(?:bereits|schon)\s+gemeldet|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i; + /** * If the final answer says a login failed / is missing, return the sentence that says so (≤300 chars). * Used when the run did not call report_missing_login itself. @@ -847,7 +851,7 @@ export function detectLoginFailure(text: string): string | null { .map((s) => s.replace(/^[\s>*#\-–•\d.)]+/, "").trim()) .filter(Boolean); for (const sentence of sentences) { - if (LOGIN_FAILURE_PATTERNS.some((re) => re.test(sentence))) { + if (LOGIN_FAILURE_PATTERNS.some((re) => re.test(sentence)) && !ALREADY_REPORTED.test(sentence)) { return sentence.length > 300 ? `${sentence.slice(0, 299)}…` : sentence; } } diff --git a/packages/core/test/missing-logins.test.ts b/packages/core/test/missing-logins.test.ts index 5523fe2c..d086fdd5 100644 --- a/packages/core/test/missing-logins.test.ts +++ b/packages/core/test/missing-logins.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ServerEvent } from "@godmode/shared"; import { loadConfig } from "../src/config"; -import { closeDb, insert, openDb } from "../src/db"; +import { closeDb, getDb, insert, openDb } from "../src/db"; +import { MIGRATIONS } from "../src/db/migrations"; import { bus } from "../src/events/bus"; import { setLogLevel } from "../src/log"; import { resetSettingsCache } from "../src/services/settings"; @@ -134,4 +135,15 @@ describe("missing logins", () => { expect(item.kind).toBe("other"); expect(catchHttp(() => reportMissingLogin({ ...base, kind: "other", service: " " })).status).toBe(400); }); + + test("open items without a site from older builds are dismissed once", () => { + const unknown = reportMissingLogin({ ...base, workspaceId: "wsp_old", kind: "other", service: "Unknown service", reason: "it's already on your list" }); + const named = reportMissingLogin({ ...base, workspaceId: "wsp_old2", kind: "other", service: "Unknown service", url: "https://unknown.example.com" }); + const real = reportMissingLogin({ ...base, workspaceId: "wsp_old", kind: "other", service: "billing.example.com" }); + getDb().run(MIGRATIONS.find((m) => m.name === "dismiss_unknown_missing_logins")!.sql); + const status = (id: string) => listMissingLogins().find((i) => i.id === id)?.status; + expect(status(unknown.id)).toBe("dismissed"); + expect(status(named.id)).toBe("open"); + expect(status(real.id)).toBe("open"); + }); }); diff --git a/packages/core/test/stream.test.ts b/packages/core/test/stream.test.ts index 3d2d7018..1686cac1 100644 --- a/packages/core/test/stream.test.ts +++ b/packages/core/test/stream.test.ts @@ -657,6 +657,9 @@ describe("helpers", () => { expect(detectLoginFailure("I logged in and downloaded the invoice.")).toBeNull(); expect(detectLoginFailure("No login required — the page is public.")).toBeNull(); expect(detectLoginFailure("I couldn't find the log in button at first, but then succeeded.")).toBeNull(); + expect(detectLoginFailure("The second didn't go out because the forum login failed; it reported that, and it's already on your list.")).toBeNull(); + expect(detectLoginFailure("The shop login failed again, which is still on your list.")).toBeNull(); + expect(detectLoginFailure("Der Login ist fehlgeschlagen. Couldn't log in to the forum, das habe ich bereits gemeldet.")).toBeNull(); }); test("usedBrowser", () => { From 228a872a092d816c9ae38417dac1105e06740904 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 19:20:18 +0200 Subject: [PATCH 3/5] Resume: read the runs that finished lately from their index Ordering by created_at let SQLite walk every chat and all of its runs to find the few cut off ones, about 130 ms on each cold start. --- packages/core/src/services/resume.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/resume.ts b/packages/core/src/services/resume.ts index 843bacb8..4a1f1132 100644 --- a/packages/core/src/services/resume.ts +++ b/packages/core/src/services/resume.ts @@ -86,6 +86,9 @@ export interface InterruptedWork { const CUT_OFF = `((r.status = 'cancelled' AND r.error = ?) OR (r.status = 'failed' AND r.error = ?))`; const LATEST = `r.id = (SELECT l.id FROM runs l WHERE l.conversation_id = r.conversation_id ORDER BY l.created_at DESC, l.rowid DESC LIMIT 1)`; +// Sorted after the fact (`+`), so SQLite reads the few runs that finished lately from idx_runs_finished instead of walking +// every chat and all of its runs (~130 ms on a cold start). +const OLDEST_FIRST = "+r.created_at, r.rowid"; function cutOffByRestart(r: Pick): boolean { return (r.status === "cancelled" && r.error === RUN_SHUT_DOWN) || (r.status === "failed" && r.error === RUN_INTERRUPTED); @@ -104,7 +107,7 @@ export function interruptedWork(): InterruptedWork { const runs = all( `SELECT r.* FROM runs r JOIN conversations c ON c.id = r.conversation_id WHERE ${CUT_OFF} AND r.finished_at > ? AND c.runner_id IS NULL AND ${LATEST} - ORDER BY r.created_at, r.rowid`, + ORDER BY ${OLDEST_FIRST}`, RUN_SHUT_DOWN, RUN_INTERRUPTED, since, @@ -347,7 +350,7 @@ function offlineFailures(): RunRow[] { `SELECT r.* FROM runs r JOIN conversations c ON c.id = r.conversation_id WHERE r.status = 'failed' AND (r.error LIKE 'API Error:%' OR r.error LIKE 'Claude Code exited%' OR r.error LIKE 'Failed to authenticate.%') AND r.finished_at > ? AND c.runner_id IS NULL AND ${LATEST} - ORDER BY r.created_at, r.rowid`, + ORDER BY ${OLDEST_FIRST}`, since, ).filter(continuesOffline); } From 10de16619e50cdd2c68011e640f48e6ba2dc8192 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 19:29:48 +0200 Subject: [PATCH 4/5] Decisions and missing logins: keep real asks and real login failures A first sentence settles a section only when it says nothing else ("None of the quotes fits. Pick A or B" still asks), and pleasantries or offers after it don't count as asking. A login failure that merely contains "it reported that" or "already flagged" is still one. --- packages/core/src/runner/openDecision.ts | 15 +++++++++++---- packages/core/src/runner/stream.ts | 2 +- packages/core/test/open-decision.test.ts | 5 +++++ packages/core/test/stream.test.ts | 4 ++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runner/openDecision.ts b/packages/core/src/runner/openDecision.ts index ef8b5a39..b5e1f7a9 100644 --- a/packages/core/src/runner/openDecision.ts +++ b/packages/core/src/runner/openDecision.ts @@ -104,6 +104,9 @@ const CHOICE = /\b(?:or|oder)\b/i; /** The section says there is nothing to decide. */ const NOTHING = /^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i; +/** A sentence that says only that ("nothing new from this run.", "None for now."), not "None of the quotes fits." */ +const NOTHING_SENTENCE = + /^(?:none|nothing|nope|n\/a|nichts|keine?[rs]?|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|input))(?: (?:new|neue[ns]?|further|more|else|weiter[es]?))?(?: (?:decisions?|questions?|entscheidung(?:en)?|fragen?))?(?: (?:from|in|for|on|von|aus|seit|für) [^.?!]{1,25})?(?: (?:for now|right now|so far|yet|today|this time|bisher|aktuell|heute|gerade))?[.!]?$/i; interface Heading { line: number; @@ -153,6 +156,12 @@ function shorten(s: string, max: number): string { const isQuestion = (s: string) => /\?["'”’)\]]*$/.test(s); +/** The sentence waits for the human: a question to them or a request, not a pleasantry or extra work on offer. */ +function asksHuman(s: string): boolean { + if (!(isQuestion(s) ? ASKS : WAITS).some((re) => re.test(s))) return false; + return !NOT_FOR_THE_HUMAN.some((re) => re.test(s)) && !(OFFERS.some((re) => re.test(s)) && !CHOICE.test(s)); +} + /** The section's question: its first sentence that asks, else its first sentence. */ function titleOf(section: string): string { const all = sentences(section); @@ -187,8 +196,7 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul const said = plain(section.split("\n").find((l) => l.trim()) ?? ""); // "nothing new from this run. You may want to …" goes on without asking: still nothing. const all = sentences(section); - const asks = all.some((s) => isQuestion(s) || WAITS.some((re) => re.test(s))); - if (!section || NOTHING.test(said) || (NOTHING.test(all[0] ?? "") && !asks)) { + if (!section || NOTHING.test(said) || (NOTHING_SENTENCE.test(all[0] ?? "") && !all.some(asksHuman))) { saidNothing = true; continue; } @@ -207,8 +215,7 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul if (!last) return null; // Only what the answer ends on: a question further up is one the agent goes on to answer itself. const asked = sentences(last).at(-1); - if (!asked || !(isQuestion(asked) ? ASKS : WAITS).some((re) => re.test(asked))) return null; - if (NOT_FOR_THE_HUMAN.some((re) => re.test(asked)) || (OFFERS.some((re) => re.test(asked)) && !CHOICE.test(asked))) return null; + if (!asked || !asksHuman(asked)) return null; const title = shorten(asked, TITLE_MAX); return { title, body: bodyOf(last, title) }; } diff --git a/packages/core/src/runner/stream.ts b/packages/core/src/runner/stream.ts index 62c00194..3d3c97d4 100644 --- a/packages/core/src/runner/stream.ts +++ b/packages/core/src/runner/stream.ts @@ -838,7 +838,7 @@ const LOGIN_FAILURE_PATTERNS: RegExp[] = [ /** The sentence passes on a login problem that is already reported ("it reported that, and it's already on your list"). */ const ALREADY_REPORTED = - /\b(?:already\s+(?:reported|flagged|filed|on\s+(?:your|the)\s+(?:list|task list|inbox))|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:it|they|he|she|which)\s+reported\s+(?:that|it|this)|(?:bereits|schon)\s+gemeldet|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i; + /\b(?:already\s+(?:reported|flagged|filed)\s+(?:it|that|this|them)\b|already\s+on\s+(?:your|the)\s+(?:list|task list|inbox)|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:bereits|schon)\s+gemeldet(?!\s*,?\s*dass)|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i; /** * If the final answer says a login failed / is missing, return the sentence that says so (≤300 chars). diff --git a/packages/core/test/open-decision.test.ts b/packages/core/test/open-decision.test.ts index 599e2a0b..95458ea0 100644 --- a/packages/core/test/open-decision.test.ts +++ b/packages/core/test/open-decision.test.ts @@ -56,12 +56,17 @@ describe("a decision left open in an answer", () => { ).toBeNull(); expect(detectOpenDecision("Shift done.\n\n4. **Needed from you:** none. I took back H-74, which was created from my \"nothing\" line.")).toBeNull(); expect(detectOpenDecision("No replies.\n\n**Needed from Dana or Sam:** nothing new. Still open from Sam: two addresses and his answers on two leads.", "Dana")).toBeNull(); + expect(detectOpenDecision("Done.\n\n**Waiting for you:** nothing new from this run. The pricing is still open on the board.\n\nAnything else?")).toBeNull(); + expect(detectOpenDecision("Done.\n\n**Waiting for you:** Nothing for now. I'll check the inbox again tomorrow.\n\nWant me to also draft the emails?")).toBeNull(); }); test("a section that starts with none but still asks", () => { expect(detectOpenDecision("Checked the inbox.\n\n**Waiting for you:** None of the leads replied. Should I send the follow-ups now or on Monday?")?.title).toBe( "Should I send the follow-ups now or on Monday?", ); + expect(detectOpenDecision("**Decision for you:** None of the three quotes fits the budget. Pick A (cheaper) or B (faster) and I'll book it.")).not.toBeNull(); + expect(detectOpenDecision("**Decisions for you:** Nothing is booked yet. Choose hotel A or hotel B for the 12th.")).not.toBeNull(); + expect(detectOpenDecision("**Entscheidung für dich:** Keine Antwort von Sam bisher. Entscheide, ob wir nachfassen.")).not.toBeNull(); }); test("a closing question to the human", () => { diff --git a/packages/core/test/stream.test.ts b/packages/core/test/stream.test.ts index 1686cac1..a9cfa6cd 100644 --- a/packages/core/test/stream.test.ts +++ b/packages/core/test/stream.test.ts @@ -660,6 +660,10 @@ describe("helpers", () => { expect(detectLoginFailure("The second didn't go out because the forum login failed; it reported that, and it's already on your list.")).toBeNull(); expect(detectLoginFailure("The shop login failed again, which is still on your list.")).toBeNull(); expect(detectLoginFailure("Der Login ist fehlgeschlagen. Couldn't log in to the forum, das habe ich bereits gemeldet.")).toBeNull(); + expect(detectLoginFailure("I couldn't log in to the supplier portal: it reported that the password is invalid.")).not.toBeNull(); + expect(detectLoginFailure("Login failed because the account is already flagged for suspicious activity.")).not.toBeNull(); + expect(detectLoginFailure("I couldn't log in to the tax portal to check the return that was already filed last week.")).not.toBeNull(); + expect(detectLoginFailure("Couldn't log in, das Portal hat schon gemeldet, dass das Konto gesperrt ist.")).not.toBeNull(); }); test("usedBrowser", () => { From 293790358dbfffb129d70fbe2b099f793946ab42 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Mon, 19 Oct 2026 19:38:49 +0200 Subject: [PATCH 5/5] Decisions and missing logins: a plain question under a decision heading still asks "No new decisions. Still open: go live Friday or Monday?" asks even without a "should I". A "nothing" sentence may only name where from ("from this run", "von mir"), so "None from the shortlist works" is no empty section. "already reported this account" is still a failure. --- packages/core/src/runner/openDecision.ts | 12 ++++++------ packages/core/src/runner/stream.ts | 2 +- packages/core/test/open-decision.test.ts | 5 +++++ packages/core/test/stream.test.ts | 2 ++ 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runner/openDecision.ts b/packages/core/src/runner/openDecision.ts index b5e1f7a9..3f8e9acf 100644 --- a/packages/core/src/runner/openDecision.ts +++ b/packages/core/src/runner/openDecision.ts @@ -106,7 +106,7 @@ const NOTHING = /^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i; /** A sentence that says only that ("nothing new from this run.", "None for now."), not "None of the quotes fits." */ const NOTHING_SENTENCE = - /^(?:none|nothing|nope|n\/a|nichts|keine?[rs]?|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|input))(?: (?:new|neue[ns]?|further|more|else|weiter[es]?))?(?: (?:decisions?|questions?|entscheidung(?:en)?|fragen?))?(?: (?:from|in|for|on|von|aus|seit|für) [^.?!]{1,25})?(?: (?:for now|right now|so far|yet|today|this time|bisher|aktuell|heute|gerade))?[.!]?$/i; + /^(?:none|nothing|nope|n\/a|nichts|keine?[rs]?|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|input))(?: (?:new|neue[ns]?|further|more|else|weiter[es]?))?(?: (?:decisions?|questions?|entscheidung(?:en)?|fragen?))?(?: (?:from|in|for|on|von|aus|seit|für) (?:(?:this|that|the|my|your|today'?s|diesem|dieser|diesen|meiner|meinem|deiner) [\p{L}'-]+|me|you|us|mir|dir|dich|euch|uns|heute|gestern|yesterday|today))?(?: (?:for now|right now|so far|yet|today|this time|bisher|aktuell|heute|gerade))?[.!]?$/iu; interface Heading { line: number; @@ -156,9 +156,8 @@ function shorten(s: string, max: number): string { const isQuestion = (s: string) => /\?["'”’)\]]*$/.test(s); -/** The sentence waits for the human: a question to them or a request, not a pleasantry or extra work on offer. */ -function asksHuman(s: string): boolean { - if (!(isQuestion(s) ? ASKS : WAITS).some((re) => re.test(s))) return false; +/** Not a pleasantry, a question to someone else or extra work on offer. */ +function forTheHuman(s: string): boolean { return !NOT_FOR_THE_HUMAN.some((re) => re.test(s)) && !(OFFERS.some((re) => re.test(s)) && !CHOICE.test(s)); } @@ -196,7 +195,8 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul const said = plain(section.split("\n").find((l) => l.trim()) ?? ""); // "nothing new from this run. You may want to …" goes on without asking: still nothing. const all = sentences(section); - if (!section || NOTHING.test(said) || (NOTHING_SENTENCE.test(all[0] ?? "") && !all.some(asksHuman))) { + const asks = all.some((s) => (isQuestion(s) || WAITS.some((re) => re.test(s))) && forTheHuman(s)); + if (!section || NOTHING.test(said) || (NOTHING_SENTENCE.test(all[0] ?? "") && !asks)) { saidNothing = true; continue; } @@ -215,7 +215,7 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul if (!last) return null; // Only what the answer ends on: a question further up is one the agent goes on to answer itself. const asked = sentences(last).at(-1); - if (!asked || !asksHuman(asked)) return null; + if (!asked || !(isQuestion(asked) ? ASKS : WAITS).some((re) => re.test(asked)) || !forTheHuman(asked)) return null; const title = shorten(asked, TITLE_MAX); return { title, body: bodyOf(last, title) }; } diff --git a/packages/core/src/runner/stream.ts b/packages/core/src/runner/stream.ts index 3d3c97d4..da7ab9c9 100644 --- a/packages/core/src/runner/stream.ts +++ b/packages/core/src/runner/stream.ts @@ -838,7 +838,7 @@ const LOGIN_FAILURE_PATTERNS: RegExp[] = [ /** The sentence passes on a login problem that is already reported ("it reported that, and it's already on your list"). */ const ALREADY_REPORTED = - /\b(?:already\s+(?:reported|flagged|filed)\s+(?:it|that|this|them)\b|already\s+on\s+(?:your|the)\s+(?:list|task list|inbox)|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:bereits|schon)\s+gemeldet(?!\s*,?\s*dass)|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i; + /\b(?:already\s+(?:reported|flagged|filed)(?:\s+(?:it|that|this|them))?(?=\s*(?:[,.;!)]|$)|\s+(?:to|for)\s+you\b|\s+on\s+your\b)|already\s+on\s+(?:your|the)\s+(?:list|task list|inbox)|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:bereits|schon)\s+gemeldet(?!\s*,?\s*dass)|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i; /** * If the final answer says a login failed / is missing, return the sentence that says so (≤300 chars). diff --git a/packages/core/test/open-decision.test.ts b/packages/core/test/open-decision.test.ts index 95458ea0..cfad68c8 100644 --- a/packages/core/test/open-decision.test.ts +++ b/packages/core/test/open-decision.test.ts @@ -67,6 +67,11 @@ describe("a decision left open in an answer", () => { expect(detectOpenDecision("**Decision for you:** None of the three quotes fits the budget. Pick A (cheaper) or B (faster) and I'll book it.")).not.toBeNull(); expect(detectOpenDecision("**Decisions for you:** Nothing is booked yet. Choose hotel A or hotel B for the 12th.")).not.toBeNull(); expect(detectOpenDecision("**Entscheidung für dich:** Keine Antwort von Sam bisher. Entscheide, ob wir nachfassen.")).not.toBeNull(); + expect(detectOpenDecision("**Decisions for you:** No new decisions. Still open from yesterday: go live Friday or Monday?")?.title).toBe( + "Still open from yesterday: go live Friday or Monday?", + ); + expect(detectOpenDecision("**Decision for you:** None from the shortlist works. Choose A or B and I'll book it.")).not.toBeNull(); + expect(detectOpenDecision("**Entscheidung für dich:** Keiner von beiden Anbietern passt. Nimm A oder B, dann buche ich.")).not.toBeNull(); }); test("a closing question to the human", () => { diff --git a/packages/core/test/stream.test.ts b/packages/core/test/stream.test.ts index a9cfa6cd..0ed4b983 100644 --- a/packages/core/test/stream.test.ts +++ b/packages/core/test/stream.test.ts @@ -664,6 +664,8 @@ describe("helpers", () => { expect(detectLoginFailure("Login failed because the account is already flagged for suspicious activity.")).not.toBeNull(); expect(detectLoginFailure("I couldn't log in to the tax portal to check the return that was already filed last week.")).not.toBeNull(); expect(detectLoginFailure("Couldn't log in, das Portal hat schon gemeldet, dass das Konto gesperrt ist.")).not.toBeNull(); + expect(detectLoginFailure("Login failed; the site already reported this account as locked.")).not.toBeNull(); + expect(detectLoginFailure("The forum login failed, which I already flagged for you.")).toBeNull(); }); test("usedBrowser", () => {