- 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 9a614db..31bf0f3 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 7841037..9401a11 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 7ef775c..24b9bd6 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 900e0f0..8547cfa 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 9bba704..2f71a18 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 f88794b..edcb30d 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 4a48be4..5b9dcd8 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 4a0519f..28ce388 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 5f08c81..8c20af6 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 d5fb1bc..10c7653 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 71b6142..da61147 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 957232b..9ca9f5c 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);
});
});