From 8a789835122a77050f58935580bdcb1caeb083df Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 4 Oct 2026 23:29:32 +0200 Subject: [PATCH] Fix what the review of search and the away summary found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search (⌘K): - What the core found in messages, and tickets or automations matched by their description, stay while typing (cmdk reads keywords only when an item mounts: they are keyed by the search). - Chats matching here and chats the core found in their messages show together; results of an earlier search never stand in for the new one; the core matches word by word ("invoice march" finds "March invoice review"). - Items with the same text stay apart (no stuck keyboard); "#12" puts #12 first. - Matching ignores accents and folds case for every script; Chinese, Japanese and Korean words match anywhere. While you were away: - Covers the time until the human came back, not up to now; at most 31 days. - Found through new indexes on when runs ended and on ticket events (migration 58) instead of reading every run. - Shows up after the click is released, so it never moves what the click was meant for. - Lists only chats still unread (read on the phone or elsewhere = not news); totals and per-agent costs agree; automations with the same name stay apart; a delivered ticket names who delivered it. --- .../src/components/attention/away-summary.tsx | 12 ++-- .../src/components/layout/command-palette.tsx | 57 ++++++++++------ apps/desktop/src/lib/api.ts | 2 +- apps/desktop/src/lib/hooks.ts | 11 ++- apps/desktop/src/lib/presence.ts | 5 +- apps/desktop/src/lib/queryKeys.ts | 2 +- apps/desktop/src/stores/ui.ts | 10 +-- docs/ARCHITECTURE.md | 12 ++-- packages/core/src/db/migrations.ts | 9 +++ packages/core/src/server/routes/chat.ts | 2 +- packages/core/src/services/away.ts | 68 +++++++++++-------- packages/core/src/services/conversations.ts | 6 +- packages/core/test/away.test.ts | 25 ++++++- 13 files changed, 144 insertions(+), 77 deletions(-) diff --git a/apps/desktop/src/components/attention/away-summary.tsx b/apps/desktop/src/components/attention/away-summary.tsx index 8ac03225..daafa6e4 100644 --- a/apps/desktop/src/components/attention/away-summary.tsx +++ b/apps/desktop/src/components/attention/away-summary.tsx @@ -22,10 +22,10 @@ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? o */ export function AwaySummaryCard({ agents }: { agents: Agent[] }) { const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]); - const since = useUi((s) => s.awaySince); - const setAwaySince = useUi((s) => s.setAwaySince); - const { data } = useAway(since); - if (!since || !data || (data.finished === 0 && data.delivered === 0)) return null; + const away = useUi((s) => s.away); + const setAway = useUi((s) => s.setAway); + const { data } = useAway(away); + if (!away || !data || (data.finished === 0 && data.delivered === 0)) return null; const facts = [ plural(data.finished, "run") + " finished", data.failed ? plural(data.failed, "problem") : null, @@ -42,9 +42,9 @@ export function AwaySummaryCard({ agents }: { agents: Agent[] }) { >

- While you were away · {formatDistanceStrict(new Date(since), new Date())} + While you were away · {formatDistanceStrict(new Date(away.since), new Date(away.until))}

-
diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index 9a614dbb..31bf0f3f 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -76,11 +76,19 @@ const SETTINGS: { id: string; label: string; icon: LucideIcon; words: string }[] { id: "about", label: "About", icon: Info, words: "version updates" }, ]; +/** Matches ignore case and accents: "cafe" finds "Café", "istanbul" finds "İstanbul". */ +const fold = (s: string) => s.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase(); +/** Scripts written without spaces between words: there a word may start anywhere. */ +const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]/u; +/** Keeps two items with the same text apart (two automations may share a name); never searched. */ +const ID_MARK = "\u2063"; +const unique = (text: string, id: string) => `${text}${ID_MARK}${id}`; + /** Every word typed starts a word somewhere — no stray letters: "invoice" doesn't find "Voice", "sign in" not "redesign". */ function wordFilter(value: string, search: string, keywords?: string[]): number { - const words = search.toLowerCase().split(/\s+/).filter(Boolean); - const text = `${value} ${keywords?.join(" ") ?? ""}`.toLowerCase(); - return words.every((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(text)) ? 1 : 0; + const words = fold(search).split(/\s+/).filter(Boolean); + const text = fold(`${value.split(ID_MARK)[0]} ${keywords?.join(" ") ?? ""}`); + return words.every((w) => (UNSPACED.test(w) ? text.includes(w) : new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(text))) ? 1 : 0; } export function CommandPalette() { @@ -98,16 +106,16 @@ export function CommandPalette() { // What the core finds in chats (message text too), a moment after typing stops. const [deferred, setDeferred] = useState(""); useEffect(() => { - const t = setTimeout(() => setDeferred(q.trim()), 200); + const t = setTimeout(() => setDeferred(q.trim()), 250); return () => clearTimeout(t); }, [q]); useEffect(() => { if (!open) setQ(""); }, [open]); - const { data: found } = useQuery({ + const { data: found, isPlaceholderData: foundBefore } = useQuery({ queryKey: qk.conversations("all", deferred), queryFn: () => api.conversations.list({ search: deferred, limit: 20 }), - enabled: open && deferred.length >= 2, + enabled: open && deferred.length >= 3, placeholderData: keepPreviousData, }); const { data: waiting = [] } = useQuery({ queryKey: qk.attention, queryFn: api.attention, enabled: open, staleTime: 2_000 }); @@ -122,10 +130,15 @@ export function CommandPalette() { // Tickets by number ("#12", "12") or title; automations by name or what they do. Capped: the list stays short. const number = /^#?(\d+)$/.exec(query)?.[1]; const ticketHits = searching - ? tasks.filter((t) => (number ? String(t.number).startsWith(number) : wordFilter(`${t.title} ${t.description ?? ""}`, q) > 0)).slice(0, 8) + ? number + ? tasks.filter((t) => String(t.number).startsWith(number)).sort((a, b) => a.number - b.number).slice(0, 8) + : tasks.filter((t) => wordFilter(`${t.title} ${t.description ?? ""}`, q) > 0).slice(0, 8) : []; const routineHits = searching ? routines.filter((r) => wordFilter(`${r.name} ${r.prompt}`, q) > 0).slice(0, 6) : []; - const chats = searching && deferred.length >= 2 && found ? found : conversations.slice(0, 50); + // Chats whose title or preview match here, then what the core found in their messages (for this very search). + const chats = searching ? conversations.filter((c) => wordFilter(`${c.title} ${c.preview ?? ""}`, q) > 0) : conversations.slice(0, 50); + const fresh = searching && deferred === q.trim() && !foundBefore ? (found ?? []) : []; + const inMessages = fresh.filter((c) => !chats.some((l) => l.id === c.id)); return ( @@ -138,7 +151,7 @@ export function CommandPalette() { {(searching ? waiting : waiting.slice(0, 5)).map((item) => { const Icon = ATTENTION_ICON[item.kind]; return ( - go(item.link)}> + go(item.link)}> {item.title} {item.detail && {item.detail}} @@ -153,7 +166,8 @@ export function CommandPalette() { {ticketHits.length > 0 && ( {ticketHits.map((t) => ( - go(`/tasks?task=${t.id}`)}> + // Keyed by the search: cmdk reads `keywords` only when an item mounts, and these were matched here already. + go(`/tasks?task=${t.id}`)}> #{t.number} {t.title} @@ -164,7 +178,7 @@ export function CommandPalette() { {routineHits.length > 0 && ( {routineHits.map((r) => ( - go(`/automations?edit=${r.id}`)}> + go(`/automations?edit=${r.id}`)}> {r.name} {!r.enabled && off} @@ -211,7 +225,7 @@ export function CommandPalette() { agents.map((a) => ( { setOpen(false); startChat.mutate(a); @@ -305,7 +319,7 @@ export function CommandPalette() { {agents.map((a) => ( - go(`/agents/${a.id}`)}> + go(`/agents/${a.id}`)}> {a.name} {a.description} @@ -314,22 +328,23 @@ export function CommandPalette() { )} - {chats.length > 0 && ( + {chats.length + inMessages.length > 0 && ( <> {chats.map((c) => ( - go(`/chat/${c.id}`)} - > + go(`/chat/${c.id}`)}> {c.title} {c.preview && {c.preview}} ))} + {inMessages.map((c) => ( + // Found by the core in its messages: kept whatever the title says (keyed by the search, see Tickets). + go(`/chat/${c.id}`)}> + {c.title} + mentioned in a message + + ))} )} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 7841037e..9401a119 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -412,7 +412,7 @@ export const api = { /** Everything that waits for the human ("Needs you"), from live state. */ attention: () => get("/api/attention"), /** What the team did since the human was last here. */ - away: (since: string) => get("/api/away", { since }), + away: (since: string, until: string) => get("/api/away", { since, until }), budgets: { get: () => get("/api/budgets"), diff --git a/apps/desktop/src/lib/hooks.ts b/apps/desktop/src/lib/hooks.ts index 7ef775cc..24b9bd64 100644 --- a/apps/desktop/src/lib/hooks.ts +++ b/apps/desktop/src/lib/hooks.ts @@ -88,9 +88,14 @@ export function useAttention() { return useQuery({ queryKey: qk.attention, queryFn: api.attention, staleTime: 2_000 }); } -/** What the team did since `since` (null = not asked). */ -export function useAway(since: string | null) { - return useQuery({ queryKey: qk.away(since ?? ""), queryFn: () => api.away(since!), enabled: !!since, staleTime: 60_000 }); +/** What the team did while the human was away (null = not asked). */ +export function useAway(away: { since: string; until: string } | null) { + return useQuery({ + queryKey: qk.away(away?.since ?? "", away?.until ?? ""), + queryFn: () => api.away(away!.since, away!.until), + enabled: !!away, + staleTime: 60_000, + }); } export function useBudgets() { diff --git a/apps/desktop/src/lib/presence.ts b/apps/desktop/src/lib/presence.ts index 900e0f0c..8547cfa7 100644 --- a/apps/desktop/src/lib/presence.ts +++ b/apps/desktop/src/lib/presence.ts @@ -28,7 +28,7 @@ export function startPresence(): () => void { const now = Date.now(); // Another window may have seen the human since. if (last !== null && now - last >= AWAY_MS) last = Math.max(last, stored() ?? 0); - if (last !== null && now - last >= AWAY_MS) useUi.getState().setAwaySince(new Date(last).toISOString()); + if (last !== null && now - last >= AWAY_MS) useUi.getState().setAway({ since: new Date(last).toISOString(), until: new Date(now).toISOString() }); last = now; if (now - written < WRITE_MS) return; written = now; @@ -38,7 +38,8 @@ export function startPresence(): () => void { /* private window: only this session notices */ } }; - const events = ["pointerdown", "keydown", "wheel"] as const; + // On release, not press: the summary appearing mid-click would move what the click was meant for. + const events = ["pointerup", "keyup", "wheel"] as const; for (const e of events) window.addEventListener(e, onInput, { passive: true, capture: true }); return () => { for (const e of events) window.removeEventListener(e, onInput, { capture: true }); diff --git a/apps/desktop/src/lib/queryKeys.ts b/apps/desktop/src/lib/queryKeys.ts index 9bba7049..2f71a18c 100644 --- a/apps/desktop/src/lib/queryKeys.ts +++ b/apps/desktop/src/lib/queryKeys.ts @@ -3,7 +3,7 @@ export const qk = { bootstrap: ["bootstrap"] as unknown[], /** "Needs you" — under `bootstrap`, so whatever refreshes the counts refreshes the list. */ attention: ["bootstrap", "attention"] as unknown[], - away: (since: string) => ["away", since], + away: (since: string, until: string) => ["away", since, until], authStatus: ["auth-status"] as unknown[], settings: ["settings"] as unknown[], notifications: ["notifications"] as unknown[], diff --git a/apps/desktop/src/stores/ui.ts b/apps/desktop/src/stores/ui.ts index f88794bd..edcb30dd 100644 --- a/apps/desktop/src/stores/ui.ts +++ b/apps/desktop/src/stores/ui.ts @@ -20,8 +20,8 @@ interface UiState { collapsedColumns: string[]; /** The Agents page shows cards or the org chart. */ agentsView: "grid" | "chart"; - /** The human came back after a while: since when they were away (Home sums up what happened). Not kept. */ - awaySince: string | null; + /** The human came back after a while: from when to when they were away (Home sums up what happened). Not kept. */ + away: { since: string; until: string } | null; setWorkspace: (id: string) => void; setCommandOpen: (open: boolean) => void; setVoiceMode: (on: boolean) => void; @@ -32,7 +32,7 @@ interface UiState { skipClaudeVersion: (version: string | null) => void; toggleColumn: (status: string) => void; setAgentsView: (v: "grid" | "chart") => void; - setAwaySince: (since: string | null) => void; + setAway: (away: { since: string; until: string } | null) => void; } export const useUi = create()( @@ -48,7 +48,7 @@ export const useUi = create()( skippedClaudeVersion: null, collapsedColumns: ["cancelled"], agentsView: "grid", - awaySince: null, + away: null, setWorkspace: (workspace) => set({ workspace }), setCommandOpen: (commandOpen) => set({ commandOpen }), setVoiceMode: (voiceMode) => set({ voiceMode }), @@ -62,7 +62,7 @@ export const useUi = create()( collapsedColumns: s.collapsedColumns.includes(status) ? s.collapsedColumns.filter((c) => c !== status) : [...s.collapsedColumns, status], })), setAgentsView: (agentsView) => set({ agentsView }), - setAwaySince: (awaySince) => set({ awaySince }), + setAway: (away) => set({ away }), }), { // Per computer in cloud mode: the selected workspace id belongs to one computer. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4a48be4b..5b9dcd85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -369,11 +369,13 @@ An agent that needs the human asks and waits, instead of ending its turn with a socket); showing it, or `POST /api/conversations/read` (`ids` or `"all"`), reads it — and reading a chat whose run failed clears the agent's "Last run failed". Automation, task, delegation and platform chats are never unread. -* **While you were away** (`services/away.ts`, `GET /api/away?since=`, Cloud: allowed; phone: closed). After at - least two hours without the human's input (pointer, keys, wheel in a focused window; the last time is kept per computer - in `localStorage`, `lib/presence.ts`), Home shows what the team did since: runs that ended (checks left out) and how - many failed, tickets delivered, what the work cost (the spend ledger), who worked, and up to six things worth a look — - delivered tickets, problems (chats and automations), then replies — one line per chat or automation. Closed with ×. +* **While you were away** (`services/away.ts`, `GET /api/away?since=&until=`, Cloud: allowed; phone: closed). + After at least two hours without the human's input (released pointer or key, wheel, in a focused window; the last time + is kept per computer in `localStorage`, `lib/presence.ts`), Home shows what the team did from then until they came + back (at most 31 days): runs that ended (checks left out) and how many failed, tickets delivered (by whom), what the + work cost (the spend ledger), who worked, and up to six things worth a look — delivered tickets, problems (chats still + unread, and automations), then replies in chats still unread — one line per chat or automation. Runs are found + through `idx_runs_finished` (migration 58). Closed with ×. * **Notices** (`services/runNotices.ts`). Such a run that nobody watched notifies once — "Mia replied in “Q4 plan”" / "Mia ran into a problem in “…”" — unless the agent called `notify_user` itself (the tool call, not the word) or the run reported a missing login (that has its own notice). Only the computer the human uses tells: a runner's runs diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 4a0519f5..28ce3880 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1019,6 +1019,15 @@ ALTER TABLE paused_runs ADD COLUMN exempt INTEGER NOT NULL DEFAULT 0; -- A sub-ticket: part of a bigger ticket (its parent waits until its sub-tickets are done, then continues with them). ALTER TABLE tasks ADD COLUMN parent_id TEXT; CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id) WHERE parent_id IS NOT NULL; +`, + }, + { + id: 58, + name: "away_indexes", + sql: /* sql */ ` +-- "While you were away" reads what ended, and what was delivered, in a window of time. +CREATE INDEX IF NOT EXISTS idx_runs_finished ON runs(finished_at) WHERE finished_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_task_events_kind ON task_events(kind, created_at); `, }, ]; diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts index 5f08c811..8c20af60 100644 --- a/packages/core/src/server/routes/chat.ts +++ b/packages/core/src/server/routes/chat.ts @@ -121,7 +121,7 @@ export function registerChatRoutes(app: Hono): void { app.get("/api/attention", (c) => c.json(listAttention())); // What the team did since the human was last here (Home's "while you were away"). - app.get("/api/away", (c) => c.json(awaySummary(c.req.query("since") ?? ""))); + app.get("/api/away", (c) => c.json(awaySummary(c.req.query("since") ?? "", c.req.query("until") || undefined))); app.patch("/api/conversations/:id", async (c) => { const patch = await body( diff --git a/packages/core/src/services/away.ts b/packages/core/src/services/away.ts index d5fb1bcd..10c76537 100644 --- a/packages/core/src/services/away.ts +++ b/packages/core/src/services/away.ts @@ -1,6 +1,6 @@ /** - * "While you were away": what the team did since the human was last at the computer — computed from what is stored - * (runs that ended, the spend ledger, ticket events), so it is right however long they were gone. + * "While you were away": what the team did while the human was gone — from `since` until they came back — computed + * from what is stored (runs that ended, the spend ledger, ticket events), so it is right however long they were gone. */ import type { AwayHighlight, AwaySummary } from "@godmode/shared"; import { all, get } from "../db"; @@ -10,17 +10,25 @@ import { badRequest } from "../util"; const HUMAN_ORIGINS = ["chat", "api"]; const HUMAN_TRIGGERS = ["chat", "manual", "api", "followup"]; const MAX_HIGHLIGHTS = 6; +/** Looked at for highlights, newest first: enough for six lines, however busy the team was. */ +const MAX_CANDIDATES = 300; +/** Further back than this, a summary isn't news anymore (and stays cheap). */ +const MAX_WINDOW_MS = 31 * 86_400_000; const RANK: Record = { delivered: 0, failed: 1, automation: 1, replied: 2 }; +/** Runs that ended in the window; `+` keeps SQLite on the finished_at index. Condition checks aren't work to report. */ +const ENDED = "r.finished_at >= ? AND r.finished_at <= ? AND +r.status IN ('succeeded', 'failed') AND r.trigger != 'check'"; -export function awaySummary(since: string): AwaySummary { - const t = Date.parse(since); - if (!Number.isFinite(t) || t >= Date.now()) throw badRequest("since must be a date and time in the past"); - const from = new Date(t).toISOString(); +export function awaySummary(since: string, until?: string): AwaySummary { + const end = until ? Date.parse(until) : Date.now(); + const start = Date.parse(since); + if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end || start >= Date.now()) throw badRequest("since must be a date and time in the past, before until"); + const from = new Date(Math.max(start, end - MAX_WINDOW_MS)).toISOString(); + const to = new Date(Math.min(end, Date.now())).toISOString(); const names = new Map(all<{ id: string; name: string }>("SELECT id, name FROM agents").map((a) => [a.id, a.name])); const nameOf = (id: string | null) => (id ? (names.get(id) ?? "An agent") : "An agent"); + const counts = get<{ n: number; failed: number | null }>(`SELECT COUNT(*) AS n, SUM(r.status = 'failed') AS failed FROM runs r WHERE ${ENDED}`, from, to); const runs = all<{ - id: string; agent_id: string; status: string; trigger: string; @@ -28,29 +36,33 @@ export function awaySummary(since: string): AwaySummary { conversation_id: string; title: string | null; origin: string | null; + unread: number; + routine_id: string | null; routine: string | null; task_id: string | null; }>( - `SELECT r.id, r.agent_id, r.status, r.trigger, r.finished_at, r.conversation_id, c.title, c.origin, rt.name AS routine, t.id AS task_id + `SELECT r.agent_id, r.status, r.trigger, r.finished_at, r.conversation_id, c.title, c.origin, c.unread_run_id IS NOT NULL AS unread, + r.routine_id, rt.name AS routine, t.id AS task_id FROM runs r LEFT JOIN conversations c ON c.id = r.conversation_id LEFT JOIN routines rt ON rt.id = r.routine_id LEFT JOIN tasks t ON t.conversation_id = r.conversation_id - WHERE r.finished_at >= ? AND r.status IN ('succeeded', 'failed') AND r.trigger != 'check' - ORDER BY r.finished_at DESC`, + WHERE ${ENDED} ORDER BY r.finished_at DESC LIMIT ${MAX_CANDIDATES}`, from, + to, ); const highlights: AwayHighlight[] = []; // One line per chat (its latest turn) and per automation, so a busy chat doesn't fill the list. const seen = new Set(); for (const r of runs) { - if (r.trigger === "routine" && r.routine) { - if (r.status !== "failed" || seen.has(`routine:${r.routine}`)) continue; - seen.add(`routine:${r.routine}`); - highlights.push({ kind: "automation", agentId: r.agent_id, text: `“${r.routine}” failed`, link: `/chat/${r.conversation_id}`, at: r.finished_at }); + if (r.trigger === "routine" && r.routine_id) { + if (r.status !== "failed" || seen.has(`routine:${r.routine_id}`)) continue; + seen.add(`routine:${r.routine_id}`); + highlights.push({ kind: "automation", agentId: r.agent_id, text: `“${r.routine ?? "An automation"}” failed`, link: `/chat/${r.conversation_id}`, at: r.finished_at }); continue; } - // Tickets show as delivered (below); dreams, delegated and platform chats aren't the human's conversations. - if (r.task_id || !r.origin || !HUMAN_ORIGINS.includes(r.origin) || !HUMAN_TRIGGERS.includes(r.trigger) || seen.has(r.conversation_id)) continue; + // Tickets show as delivered (below); dreams, delegated and platform chats aren't the human's conversations, and a + // chat they read since (here, on the phone, in another window) isn't news. + if (r.task_id || !r.origin || !HUMAN_ORIGINS.includes(r.origin) || !HUMAN_TRIGGERS.includes(r.trigger) || !r.unread || seen.has(r.conversation_id)) continue; seen.add(r.conversation_id); const failed = r.status === "failed"; highlights.push({ @@ -62,39 +74,39 @@ export function awaySummary(since: string): AwaySummary { }); } - // The latest delivery per ticket (SQLite takes the other columns from the row with the MAX). + // The latest delivery per ticket (SQLite takes the other columns from the row with the MAX), by who delivered it. const delivered = all<{ task_id: string; number: number; title: string; agent_id: string | null; actor_name: string; created_at: string }>( `SELECT e.task_id, t.number, t.title, t.agent_id, e.actor_name, MAX(e.created_at) AS created_at FROM task_events e JOIN tasks t ON t.id = e.task_id - WHERE e.kind = 'delivered' AND e.created_at >= ? GROUP BY e.task_id ORDER BY created_at DESC`, + WHERE e.kind = 'delivered' AND e.created_at >= ? AND e.created_at <= ? GROUP BY e.task_id ORDER BY created_at DESC`, from, + to, ); for (const d of delivered) { - const who = d.agent_id ? nameOf(d.agent_id) : d.actor_name || "An agent"; + const who = d.actor_name || nameOf(d.agent_id); highlights.push({ kind: "delivered", agentId: d.agent_id, text: `${who} delivered #${d.number} ${d.title}`, link: `/tasks?task=${d.task_id}`, at: d.created_at }); } - // Who worked: the runs that ended (as counted above) and what each agent's work cost since (also unfinished work). + // Who worked: the runs that ended (as counted above), and what each agent's work cost (checks included, as the total). const byAgent = new Map(); - for (const r of runs) { - const a = byAgent.get(r.agent_id) ?? { name: nameOf(r.agent_id), runs: 0, cost: 0 }; - a.runs++; - byAgent.set(r.agent_id, a); + for (const row of all<{ agent_id: string; runs: number }>(`SELECT r.agent_id, COUNT(*) AS runs FROM runs r WHERE ${ENDED} GROUP BY r.agent_id`, from, to)) { + byAgent.set(row.agent_id, { name: nameOf(row.agent_id), runs: row.runs, cost: 0 }); } for (const row of all<{ agent_id: string; agent_name: string; cost: number }>( - "SELECT agent_id, MAX(agent_name) AS agent_name, SUM(cost_usd) AS cost FROM spend WHERE at >= ? AND trigger != 'check' GROUP BY agent_id", + "SELECT agent_id, MAX(agent_name) AS agent_name, SUM(cost_usd) AS cost FROM spend WHERE at >= ? AND at <= ? GROUP BY agent_id", from, + to, )) { const a = byAgent.get(row.agent_id) ?? { name: names.get(row.agent_id) ?? row.agent_name, runs: 0, cost: 0 }; a.cost = row.cost ?? 0; byAgent.set(row.agent_id, a); } - const cost = get<{ cost: number | null }>("SELECT SUM(cost_usd) AS cost FROM spend WHERE at >= ?", from)?.cost ?? 0; + const cost = [...byAgent.values()].reduce((sum, a) => sum + a.cost, 0); const round = (usd: number) => Math.round(usd * 10_000) / 10_000; return { since: from, - finished: runs.length, - failed: runs.filter((r) => r.status === "failed").length, + finished: counts?.n ?? 0, + failed: counts?.failed ?? 0, delivered: delivered.length, costUsd: round(cost), agents: [...byAgent] diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index 71b6142a..da61147d 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -348,9 +348,9 @@ export function listConversations( where.push("(c.workspace_id = ? OR c.agent_id IN (SELECT id FROM agents WHERE workspace_id = ?))"); params.push(opts.workspaceId, opts.workspaceId); } - const search = opts.search?.trim(); - if (search) { - const like = `%${search.replace(/[\\%_]/g, (m) => `\\${m}`)}%`; + // Every word, in the title or in a message ("invoice march" finds "March invoice review"); at most six words. + for (const word of (opts.search ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 6)) { + const like = `%${word.replace(/[\\%_]/g, (m) => `\\${m}`)}%`; where.push( "(c.title LIKE ? ESCAPE '\\' OR EXISTS (SELECT 1 FROM messages m2 WHERE m2.conversation_id = c.id AND m2.content LIKE ? ESCAPE '\\'))", ); diff --git a/packages/core/test/away.test.ts b/packages/core/test/away.test.ts index 957232ba..9ca9f5c5 100644 --- a/packages/core/test/away.test.ts +++ b/packages/core/test/away.test.ts @@ -1,7 +1,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import type { Agent, AwaySummary } from "@godmode/shared"; import { makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; -import { insert } from "../src/db"; +import { all, insert } from "../src/db"; +import { startRunNotices, stopRunNotices } from "../src/services/runNotices"; import { getAccessToken } from "../src/server/auth"; import { deviceMayCall } from "../src/mobile/scope"; import { waitForRun } from "../src/runner/runner"; @@ -19,9 +20,12 @@ beforeAll(async () => { env = await setupEnv("godmode-away-"); mia = await makeAgent({ name: "Mia" }); bo = await makeAgent({ name: "Bo" }); + // Chats nobody looked at are unread: only those are news in the summary. + startRunNotices(); }); afterAll(async () => { + stopRunNotices(); await env.close(); }); @@ -54,6 +58,23 @@ describe("while you were away", () => { ]); expect(away.highlights[3]!.link).toBe(`/chat/${replied.conversation.id}`); + // Only until the human came back: what happened after that isn't "while you were away". + const back = new Date().toISOString(); + await new Promise((r) => setTimeout(r, 20)); + const later = await startChat({ agentId: mia.id, content: "Say hello", title: "After I came back" }); + await waitForRun(later.run.id, 20_000); + const bounded = awaySummary(before, back); + expect(bounded.finished).toBe(3); + expect(bounded.highlights.map((h) => h.text)).not.toContain("Mia replied in “After I came back”"); + expect(awaySummary(before).highlights.map((h) => h.text)).toContain("Mia replied in “After I came back”"); + // A chat read since (here or on the phone) isn't news. + const { markConversationsRead } = await import("../src/services/conversations"); + markConversationsRead([replied.conversation.id]); + expect(awaySummary(before).highlights.map((h) => h.text)).not.toContain("Mia replied in “Q4 plan”"); + // Cheap however long the history: what ended is found through its index. + const plan = all<{ detail: string }>("EXPLAIN QUERY PLAN SELECT COUNT(*) FROM runs r WHERE r.finished_at >= ? AND r.finished_at <= ? AND +r.status IN ('succeeded', 'failed') AND r.trigger != 'check'", before, back); + expect(plan.map((p) => p.detail).join(" ")).toContain("idx_runs_finished"); + // Nothing happened since. const after = new Date(Date.now() + 1).toISOString(); await new Promise((r) => setTimeout(r, 20)); @@ -68,6 +89,8 @@ describe("while you were away", () => { expect(((await ok.json()) as AwaySummary).finished).toBeGreaterThanOrEqual(3); expect((await get("yesterday-ish")).status).toBe(400); expect((await get(new Date(Date.now() + 60_000).toISOString())).status).toBe(400); + const until = await fetch(`${env.baseUrl}/api/away?since=${encodeURIComponent(new Date(Date.now() - 3_600_000).toISOString())}&until=${encodeURIComponent(new Date(Date.now() - 7_200_000).toISOString())}`, { headers: { Authorization: `Bearer ${getAccessToken()}` } }); + expect(until.status).toBe(400); expect(deviceMayCall("GET", "/api/away")).toBe(false); }); });