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/openDecision.ts b/packages/core/src/runner/openDecision.ts index bf145f9e..3f8e9acf 100644 --- a/packages/core/src/runner/openDecision.ts +++ b/packages/core/src/runner/openDecision.ts @@ -103,7 +103,10 @@ 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; +/** 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) (?:(?: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; @@ -153,6 +156,11 @@ function shorten(s: string, max: number): string { const isQuestion = (s: string) => /\?["'”’)\]]*$/.test(s); +/** 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)); +} + /** The section's question: its first sentence that asks, else its first sentence. */ function titleOf(section: string): string { const all = sentences(section); @@ -185,7 +193,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))) && forTheHuman(s)); + if (!section || NOTHING.test(said) || (NOTHING_SENTENCE.test(all[0] ?? "") && !asks)) { saidNothing = true; continue; } @@ -204,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 || !(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 67ef5642..da7ab9c9 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)(?:\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). * 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/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); } 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/open-decision.test.ts b/packages/core/test/open-decision.test.ts index d8d864ac..cfad68c8 100644 --- a/packages/core/test/open-decision.test.ts +++ b/packages/core/test/open-decision.test.ts @@ -49,6 +49,29 @@ 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(); + 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(); + 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 3d2d7018..0ed4b983 100644 --- a/packages/core/test/stream.test.ts +++ b/packages/core/test/stream.test.ts @@ -657,6 +657,15 @@ 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(); + 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(); + 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", () => {