Skip to content
708 linesCodeBlameRaw
1/**
2 * Today, the workspace's front page: how the day's agent work went, what is
3 * waiting on you, what the day cost, and the one place to start. Every
4 * number is worked out here from what the services return, so each
5 * definition is tested on its own (today.test.ts) and written down in the
6 * Today guide (apps/docs/.../guides/today.md). Imports only types.
7 */
8import type { AgentSession, AgentSessionKind, InboxItem, Pull, RepoPath, Statement } from "@g1t/contracts";
9
10const MINUTE = 60_000;
11const DAY = 24 * 60 * MINUTE;
12
13/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
14export function dayIn(at: number, timeZone: string | null): string {
15 try {
16 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
17 } catch {
18 return new Date(at).toISOString().slice(0, 10);
19 }
20}
21
22/** The `YYYY-MM-DD` keys of the `count` days before `now`'s day, newest first. */
23export function daysBefore(now: number, timeZone: string | null, count: number): string[] {
24 const today = dayIn(now, timeZone);
25 const keys: string[] = [];
26 // Noon steps never skip or repeat a day across a clock change.
27 for (let back = 1; keys.length < count && back < count + 3; back++) {
28 const key = dayIn(now - back * DAY, timeZone);
29 if (key !== today && !keys.includes(key)) keys.push(key);
30 }
31 return keys;
32}
33
34/** "Oct 9", in the reader's time zone. */
35export function shortDate(now: number, timeZone: string | null): string {
36 const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
37 try {
38 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
39 } catch {
40 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
41 }
42}
43
44// --- Today's tasks ------------------------------------------------------------
45
46/**
47 * How one of today's tasks went:
48 * - `accepted`: an agent's pull request merged today without the agent being sent back to revise it.
49 * - `fixed`: an agent's pull request merged today after one or more revisions.
50 * - `finished`: an agent session that finished today. Sessions are not reviewed, so they are never `accepted`.
51 * - `open`: still open, and worked on today.
52 * - `dropped`: an agent's pull request closed today without merging, or a session that failed or was stopped today.
53 */
54export type TaskOutcome = "accepted" | "fixed" | "finished" | "open" | "dropped";
55
56/** Where a task came from. */
57export type TaskSource = "chat" | "schedule" | "colleague" | "code";
58
59export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
60 accepted: "Accepted first time",
61 fixed: "Fixed after review",
62 finished: "Sessions finished",
63 open: "Still open",
64 dropped: "Didn't finish",
65};
66
67export const SOURCE_LABEL: Record<TaskSource, string> = {
68 chat: "Chat",
69 schedule: "Schedules",
70 colleague: "Another agent",
71 code: "Code",
72};
73
74/** One mark on the strip. */
75export type DayTask = {
76 key: string;
77 outcome: TaskOutcome;
78 source: TaskSource;
79 /** Null for a session in a conversation the viewer is not in. */
80 title: string | null;
81 to: string | null;
82 /** When it settled, or its latest activity while open: the strip's order. */
83 at: number;
84};
85
86/** An agent's pull request with where it is: what Today reads from Code. */
87export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
88 repo: RepoPath;
89 author: { username: string; kind?: string };
90};
91
92/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
93export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
94 const name = pull.author.username;
95 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
96}
97
98/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
99export function pullKey(repo: RepoPath, number: number): string {
100 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
101}
102
103/**
104 * How an agent's pull request went on `day`, or null when it is not one of
105 * that day's tasks. `revisions` counts its `revise` runs: each time g1t
106 * sent the agent back for failed checks or a review.
107 */
108export function pullOutcome(pull: CodePull, revisions: number, day: string, timeZone: string | null): TaskOutcome | null {
109 if (pull.status === "merged") {
110 if (!pull.mergedAt || dayIn(Date.parse(pull.mergedAt), timeZone) !== day) return null;
111 return revisions > 0 ? "fixed" : "accepted";
112 }
113 if (pull.status === "closed") return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "dropped" : null;
114 // Draft or open: today's when it was worked on today.
115 return dayIn(Date.parse(pull.updatedAt), timeZone) === day ? "open" : null;
116}
117
118const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
119
120/**
121 * How a session went today, or null when it is not one of today's tasks.
122 * Only a session at the root of its tree counts: the subagents and
123 * colleagues it brought in are part of its task.
124 */
125export function sessionOutcome(
126 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
127 day: string,
128 timeZone: string | null,
129): TaskOutcome | null {
130 if (session.parent_id) return null;
131 if (LIVE.has(session.status)) return dayIn(Date.parse(session.updated_at), timeZone) === day ? "open" : null;
132 const ended = session.finished_at ?? session.updated_at;
133 if (dayIn(Date.parse(ended), timeZone) !== day) return null;
134 return session.status === "done" ? "finished" : "dropped";
135}
136
137export function sessionSource(kind: AgentSessionKind): TaskSource {
138 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
139}
140
141/** The day's agent work, and how the last seven days compare. */
142export type DayWork = {
143 tasks: DayTask[];
144 counts: Record<TaskOutcome, number>;
145 /** Tasks by where they came from, most first; only sources with any. */
146 sources: { source: TaskSource; count: number }[];
147 /**
148 * Accepted first time: of the agents' pull requests that merged or closed
149 * today, the share that merged without a revision. Null with none.
150 */
151 rate: { value: number; of: number } | null;
152 /** The same share over the seven days before today; null when there were none, or they could not all be read. */
153 lastWeek: { value: number; of: number } | null;
154 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
155 code: "read" | "no_access" | "unavailable";
156 /** Whether sessions were read. */
157 sessions: "read" | "unavailable";
158 /** True when a list was cut short before the start of the day, so some of today's tasks may be missing. */
159 partial: boolean;
160};
161
162export type CodeWork = {
163 pulls: CodePull[];
164 /** `revise` runs per pull request (`pullKey`). */
165 revisions: Record<string, number>;
166 /** Whether every list read reached back eight days. */
167 complete: boolean;
168};
169
170export type SessionWork = { sessions: AgentSession[]; complete: boolean };
171
172/** Accepted first time over a set of settled outcomes: null when nothing settled. */
173export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
174 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
175 if (settled.length === 0) return null;
176 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
177}
178
179export function dayWork(input: {
180 now: number;
181 timeZone: string | null;
182 slug: string;
183 code: CodeWork | null | "no_access";
184 sessions: SessionWork | null;
185}): DayWork {
186 const { now, timeZone, slug } = input;
187 const today = dayIn(now, timeZone);
188 const tasks: DayTask[] = [];
189 let lastWeek: DayWork["lastWeek"] = null;
190 const code = input.code;
191 if (code && code !== "no_access") {
192 const week = new Set(daysBefore(now, timeZone, 7));
193 const earlier: TaskOutcome[] = [];
194 for (const pull of code.pulls) {
195 if (!isAgentPull(pull)) continue;
196 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
197 const outcome = pullOutcome(pull, revisions, today, timeZone);
198 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
199 if (outcome) {
200 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
201 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
202 continue;
203 }
204 // The seven days before: merged or closed on one of them.
205 if (pull.status === "merged" || pull.status === "closed") {
206 const settled = pull.status === "merged" ? pull.mergedAt : pull.updatedAt;
207 if (settled && week.has(dayIn(Date.parse(settled), timeZone))) {
208 earlier.push(pull.status === "closed" ? "dropped" : revisions > 0 ? "fixed" : "accepted");
209 }
210 }
211 }
212 lastWeek = code.complete ? acceptance(earlier) : null;
213 }
214 for (const session of input.sessions?.sessions ?? []) {
215 const outcome = sessionOutcome(session, today, timeZone);
216 if (!outcome) continue;
217 tasks.push({
218 key: `session:${session.id}`,
219 outcome,
220 source: sessionSource(session.kind),
221 title: session.visible ? session.title : null,
222 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
223 at: Date.parse(outcome === "open" ? session.updated_at : (session.finished_at ?? session.updated_at)),
224 });
225 }
226 // Settled work first, in the order it settled; open work last.
227 const order: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0, open: 1 };
228 tasks.sort((a, b) => order[a.outcome] - order[b.outcome] || a.at - b.at || a.key.localeCompare(b.key));
229 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, open: 0, dropped: 0 };
230 const bySource = new Map<TaskSource, number>();
231 for (const task of tasks) {
232 counts[task.outcome]++;
233 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
234 }
235 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
236 return {
237 tasks,
238 counts,
239 sources: [...bySource]
240 .map(([source, count]) => ({ source, count }))
241 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
242 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
243 lastWeek,
244 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
245 sessions: input.sessions ? "read" : "unavailable",
246 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
247 };
248}
249
250/** The change in percentage points from the last seven days, rounded: "+6 pts", "−3 pts", "Same". */
251export function trendLabel(rate: { value: number } | null, lastWeek: { value: number } | null): string | null {
252 if (!rate || !lastWeek) return null;
253 const points = Math.round(rate.value * 100) - Math.round(lastWeek.value * 100);
254 if (points === 0) return "Same as the last 7 days";
255 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the last 7 days`;
256}
257
258// --- Needs attention ----------------------------------------------------------
259
260/**
261 * What a row is. Tiers, most pressing first:
262 * 0. production is down to an older build (`deploy`), or the workspace hit its usage limit (`limit`);
263 * 1. agent work that has stopped and can't go on without you;
264 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking);
265 * 3. something stuck that may need a look;
266 * 4. an unread notification that is a warning or a failure;
267 * 5. a mention or a direct message.
268 */
269export type AttentionKind =
270 | "limit"
271 | "deploy"
272 | "session_cap"
273 | "agent_budget"
274 | "pull_blocked"
275 | "ready"
276 | "review"
277 | "invitation"
278 | "agent_waiting"
279 | "stuck"
280 | "runner"
281 | "notification"
282 | "chat";
283
284export const TIER: Record<AttentionKind, number> = {
285 limit: 0,
286 deploy: 0,
287 session_cap: 1,
288 agent_budget: 1,
289 pull_blocked: 1,
290 ready: 2,
291 review: 2,
292 invitation: 2,
293 agent_waiting: 2,
294 stuck: 3,
295 runner: 3,
296 notification: 4,
297 chat: 5,
298};
299
300export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
301
302/** One row of Needs attention. */
303export type AttentionRow = {
304 key: string;
305 kind: AttentionKind;
306 title: string;
307 /** One line: what it is and where. */
308 detail: string;
309 /** What is at stake, in a few words. */
310 stake: Stake | null;
311 /** The action, right there. */
312 action: { label: string; to: string };
313 /** Who is on it: the agent, or the person who asked. */
314 owner: { name: string; agent: boolean } | null;
315 /** Epoch ms: when it started waiting. */
316 at: number;
317 /** Where it came from, for a member without Code access never `code`. */
318 from: "code" | "agents" | "notifications" | "chat";
319};
320
321/**
322 * Most pressing first: by tier, then what has waited longest, then by key
323 * so the order never depends on the order rows were read. One of each key,
324 * and one of each place: a notification about a pull request already on
325 * the list is left out.
326 */
327export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
328 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
329 const keys = new Set<string>();
330 const places = new Set<string>();
331 return sorted.filter((row) => {
332 const place = row.action.to.split(/[?#]/)[0];
333 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
334 keys.add(row.key);
335 places.add(place);
336 return true;
337 });
338}
339
340/** Start here: the first row by `rankAttention`, with why it was picked. */
341export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
342 const row = ranked[0];
343 if (!row) return null;
344 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
345 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
346 return { row, why: `${WHY[row.kind]}${tail}` };
347}
348
349const WHY: Record<AttentionKind, string> = {
350 limit: "Agents start nothing new past the workspace's usage limit, so everything else waits on it.",
351 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
352 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
353 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
354 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
355 ready: "The work is done and only waits for you to land it.",
356 review: "You were asked by name, so it waits for your verdict.",
357 invitation: "It is addressed to you; no one else can answer it.",
358 agent_waiting: "An agent is waiting on you to go on.",
359 stuck: "A running agent has stopped reporting and may be stuck.",
360 runner: "A job is waiting for a runner that is not there.",
361 notification: "It is the most pressing unread notification.",
362 chat: "Nothing else is waiting, and this is the oldest message for you.",
363};
364
365// --- Rows from each source ----------------------------------------------------
366
367/** Mission control's need row, as Today reads it (lib/mission-control.ts `NeedRow`). */
368export type CodeNeed = {
369 key: string;
370 reason: string;
371 repo: RepoPath | null;
372 ref: string | null;
373 title: string;
374 ask: string;
375 by: { name: string; agent: boolean } | null;
376 for: string | null;
377 at: number;
378 to: string;
379 facts: { label: string; value: string; tone: string | null }[];
380};
381
382const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
383
384const REASON_STAKE: Record<string, Stake> = {
385 blocking: { text: "Blocking", tone: "danger" },
386 checks_failing: { text: "Checks failing", tone: "danger" },
387 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
388 low_confidence: { text: "Low confidence", tone: "warn" },
389 needs_review: { text: "Needs a verdict", tone: "warn" },
390 stalled: { text: "Stalled", tone: "warn" },
391};
392
393/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
394export function codeRow(need: CodeNeed): AttentionRow {
395 const prefix = need.key.split(":")[0];
396 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
397 const detail = where ? `${where} · ${need.ask}` : need.ask;
398 const owner = need.by;
399 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
400 switch (prefix) {
401 case "deploy":
402 return {
403 ...base,
404 kind: "deploy",
405 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
406 action: { label: "See the build", to: need.to },
407 };
408 case "invitation":
409 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
410 case "review": {
411 const lines = fact(need, "Lines");
412 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
413 }
414 case "runner":
415 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
416 case "run": {
417 const cost = fact(need, "Cost so far");
418 return {
419 ...base,
420 kind: "stuck",
421 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
422 action: { label: "Look", to: need.to },
423 };
424 }
425 default: {
426 if (need.reason === "ready_to_merge") {
427 const files = fact(need, "Files changed");
428 return {
429 ...base,
430 kind: "ready",
431 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
432 action: { label: "Merge", to: need.to },
433 };
434 }
435 return {
436 ...base,
437 kind: "pull_blocked",
438 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
439 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
440 };
441 }
442 }
443}
444
445/** "$4.20", or "<$0.01" for a sliver. */
446export function dollars(micros: number): string {
447 const value = micros / 1_000_000;
448 if (value > 0 && value < 0.01) return "<$0.01";
449 return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
450}
451
452/** A session stopped at its spend cap that the viewer may approve more for. */
453export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
454 const cap = session.cap_micros;
455 return {
456 key: `session:${session.id}`,
457 kind: "session_cap",
458 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
459 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
460 stake: { text: cap != null ? `${dollars(session.charged_micros)} of ${dollars(cap)} cap` : `${dollars(session.charged_micros)} spent`, tone: "warn" },
461 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
462 owner: { name: session.agent_handle, agent: true },
463 at: Date.parse(session.updated_at),
464 from: "agents",
465 };
466}
467
468export type AgentLike = {
469 id: string;
470 handle: string;
471 display_name: string;
472 status: string;
473 spent_month_micros: number;
474 budget: { monthly_micros: number | null };
475 updated_at: string;
476};
477
478/** An agent out of budget (for those who may raise it), or waiting on someone. */
479export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
480 const base = {
481 key: `agent:${agent.id}`,
482 owner: { name: agent.handle, agent: true },
483 at: Date.parse(agent.updated_at),
484 from: "agents" as const,
485 };
486 if (agent.status === "out_of_budget") {
487 const budget = agent.budget.monthly_micros;
488 return {
489 ...base,
490 kind: "agent_budget",
491 title: `${agent.display_name} is out of budget`,
492 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
493 stake: { text: budget != null ? `${dollars(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
494 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
495 };
496 }
497 if (agent.status === "waiting") {
498 return {
499 ...base,
500 kind: "agent_waiting",
501 title: `${agent.display_name} is waiting`,
502 detail: `@${agent.handle} is waiting before it goes on.`,
503 stake: null,
504 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
505 };
506 }
507 return null;
508}
509
510const NOTIFICATION_STAKE: Record<string, string> = {
511 agent: "Agent waiting",
512 review_requested: "Review requested",
513 assign: "Assigned to you",
514 mention: "Mentioned",
515 team_mention: "Team mentioned",
516 ci_activity: "CI activity",
517 security_alert: "Security alert",
518 state_change: "State changed",
519 author: "Your work",
520 comment: "Comment",
521 manual: "Subscribed",
522 subscribed: "Watching",
523};
524
525/**
526 * An unread notification that is a warning (someone or something is
527 * waiting) or a failure, in this workspace. A member without Code access
528 * gets none about a repository.
529 */
530export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
531 if (item.readAt != null || item.doneAt != null) return null;
532 if (item.severity !== "warning" && item.severity !== "error") return null;
533 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
534 if (!inWorkspace) return null;
535 if (!code && item.repo) return null;
536 return {
537 key: `notification:${item.id}`,
538 kind: "notification",
539 title: item.body || item.title,
540 detail: item.body ? item.title : (item.repo ?? "A notification"),
541 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
542 action: { label: "Open", to: item.url },
543 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
544 at: Date.parse(item.updatedAt),
545 from: "notifications",
546 };
547}
548
549export type ChatEntryLike = {
550 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
551 title: string;
552 muted: boolean;
553 unread: number;
554 mentions: number;
555};
556
557/** A conversation with a mention of you, or a direct message you haven't read. */
558export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
559 if (entry.muted) return null;
560 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
561 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
562 const dm = entry.channel.kind === "dm";
563 return {
564 key: `chat:${entry.channel.id}`,
565 kind: "chat",
566 title: dm ? entry.title : `#${entry.title}`,
567 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
568 stake: {
569 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
570 tone: entry.mentions > 0 ? "accent" : null,
571 },
572 action: { label: "Reply", to },
573 owner: null,
574 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
575 from: "chat",
576 };
577}
578
579/** What Needs attention and Start here show, and which sources did not answer. */
580export type Attention = {
581 rows: AttentionRow[];
582 start: { row: AttentionRow; why: string } | null;
583 /** Sources that did not answer, by name, for the card to say so. */
584 missing: string[];
585};
586
587export function attention(input: {
588 slug: string;
589 code: boolean;
590 codeNeeds: CodeNeed[] | null;
591 capped: AgentSession[] | null;
592 agents: AgentLike[] | null;
593 canManage: boolean;
594 notifications: InboxItem[] | null;
595 chat: ChatEntryLike[] | null;
596}): Attention {
597 const rows: AttentionRow[] = [];
598 const missing: string[] = [];
599 if (input.code) {
600 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
601 else missing.push("Code");
602 }
603 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
604 if (input.agents) {
605 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
606 for (const agent of input.agents) {
607 if (agent.status === "out_of_budget" && !input.canManage) continue;
608 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
609 const row = agentRow(agent, input.slug);
610 if (row) rows.push(row);
611 }
612 }
613 if (input.capped == null || input.agents == null) missing.push("Agents");
614 if (input.notifications) {
615 for (const item of input.notifications) {
616 const row = notificationRow(item, input.slug, input.code);
617 if (row) rows.push(row);
618 }
619 } else missing.push("Notifications");
620 if (input.chat) {
621 for (const entry of input.chat) {
622 const row = chatRow(entry, input.slug);
623 if (row) rows.push(row);
624 }
625 } else missing.push("Chat");
626 const ranked = rankAttention(rows);
627 return { rows: ranked, start: startHere(ranked), missing };
628}
629
630// --- Spent today --------------------------------------------------------------
631
632/** Money in, which the statement lists but which is not spend. */
633const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
634
635export type Spend = {
636 /** The statement's day, `YYYY-MM-DD` in UTC. */
637 day: string;
638 /** Usage at price today, in micros. */
639 totalMicros: number;
640 /** A line per kind of charge, as the statement names it, most first. */
641 lines: { kind: string; micros: number; count: number }[];
642 /** Usage at price this month so far. */
643 monthMicros: number;
644};
645
646/**
647 * Today's spend from the month's statement grouped by day: each usage
648 * line at its price (what was charged, plus what the plan, a trial, a pool
649 * or a discount paid of it), leaving out money in. The statement's days are
650 * UTC days.
651 */
652export function spendToday(statement: Pick<Statement, "groups" | "totals">, now: number): Spend {
653 const day = new Date(now).toISOString().slice(0, 10);
654 const group = statement.groups.find((g) => g.key === day);
655 const lines = (group?.lines ?? [])
656 .filter((line) => !MONEY_IN.has(line.kind))
657 .map((line) => ({ kind: line.kind, micros: line.priceMicros ?? line.chargedMicros, count: line.count }))
658 .filter((line) => line.micros > 0)
659 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
660 return {
661 day,
662 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
663 lines,
664 monthMicros: statement.totals.priceMicros ?? statement.totals.chargedMicros,
665 };
666}
667
668// --- The sentence -----------------------------------------------------------
669
670const count = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
671
672/**
673 * The one sentence under the heading: "Agents finished 35 tasks. 4 needed
674 * a fix after review, and 6 are still open." Finished counts every task
675 * that settled well: accepted, fixed, and sessions that finished.
676 */
677export function daySentence(work: Pick<DayWork, "counts" | "tasks">): string {
678 if (work.tasks.length === 0) return "No agent work today yet.";
679 const { accepted, fixed, finished, open, dropped } = work.counts;
680 const done = accepted + fixed + finished;
681 const parts: string[] = [];
682 const first = done > 0 ? `Agents finished ${count(done, "task")}.` : "Agents haven't finished a task yet today.";
683 const after: string[] = [];
684 if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`);
685 if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`);
686 if (open > 0) after.push(`${open.toLocaleString("en-US")} ${open === 1 ? "is" : "are"} still open`);
687 parts.push(first);
688 if (after.length > 0) {
689 const last = after.pop();
690 const text = after.length > 0 ? `${after.join(", ")}, and ${last}` : String(last);
691 parts.push(`${text.charAt(0).toUpperCase()}${text.slice(1)}.`);
692 }
693 return parts.join(" ");
694}
695
696/** "8 things are waiting on you.", or that nothing is. */
697export function waitingSentence(total: number): string {
698 if (total === 0) return "Nothing is waiting on you.";
699 return `${count(total, "thing")} ${total === 1 ? "is" : "are"} waiting on you.`;
700}
701
702/** How long something has waited: "45 min", "17 h", "2 d". */
703export function waited(at: number, now: number): string {
704 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
705 if (minutes < 60) return `${minutes} min`;
706 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
707 return `${Math.floor(minutes / (24 * 60))} d`;
708}