| 1 | /** |
| 2 | * Agents mode's arithmetic and wording: how a session's status and kind |
| 3 | * read, where it ran, how a routine's schedule and events read, a tree of |
| 4 | * sessions in order, memories by scope, a month's days and spend against |
| 5 | * a cap. Pure, with type-only imports, so it can be tested. |
| 6 | */ |
| 7 | import type { |
| 8 | AgentMemory, |
| 9 | AgentMemoryScope, |
| 10 | AgentSessionKind, |
| 11 | AgentSessionStatus, |
| 12 | RoutineSchedule, |
| 13 | SpendSlice, |
| 14 | } from "@g1t/contracts"; |
| 15 | |
| 16 | export type Tone = "neutral" | "accent" | "info" | "warn" | "success" | "danger"; |
| 17 | |
| 18 | /** The statuses of a session that is not over (the contract's `SESSION_LIVE`). */ |
| 19 | export const LIVE_STATUSES: readonly AgentSessionStatus[] = ["queued", "working", "waiting", "needs_approval"]; |
| 20 | |
| 21 | export function isLive(status: AgentSessionStatus | string): boolean { |
| 22 | return (LIVE_STATUSES as readonly string[]).includes(status); |
| 23 | } |
| 24 | |
| 25 | /** A session's status chip: its words, its tone, and whether it is moving now (a soft pulse). The same words as its card in chat. */ |
| 26 | export function sessionStatus(status: AgentSessionStatus | string): { label: string; tone: Tone; moving: boolean } { |
| 27 | switch (status) { |
| 28 | case "queued": |
| 29 | return { label: "Queued", tone: "neutral", moving: false }; |
| 30 | case "working": |
| 31 | return { label: "Working", tone: "accent", moving: true }; |
| 32 | case "waiting": |
| 33 | return { label: "Waiting on helpers", tone: "info", moving: true }; |
| 34 | case "needs_approval": |
| 35 | return { label: "Needs approval", tone: "warn", moving: false }; |
| 36 | case "done": |
| 37 | return { label: "Done", tone: "success", moving: false }; |
| 38 | case "failed": |
| 39 | return { label: "Failed", tone: "danger", moving: false }; |
| 40 | case "stopped": |
| 41 | return { label: "Stopped", tone: "neutral", moving: false }; |
| 42 | default: |
| 43 | return { label: String(status), tone: "neutral", moving: false }; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | const KINDS: Record<AgentSessionKind, string> = { chat: "Chat", routine: "Routine", helper: "Helper", subagent: "Subagent" }; |
| 48 | |
| 49 | /** Where a session came from, in a word. */ |
| 50 | export function kindLabel(kind: AgentSessionKind | string): string { |
| 51 | return KINDS[kind as AgentSessionKind] ?? String(kind); |
| 52 | } |
| 53 | |
| 54 | /** The conversation, as a person reads it: `#releases`, or a direct message. */ |
| 55 | export function whereLabel(at: { channel_kind: "channel" | "dm"; channel_name: string | null }): string { |
| 56 | if (at.channel_kind === "channel" && at.channel_name) return `#${at.channel_name}`; |
| 57 | return at.channel_kind === "dm" ? "A direct message" : "A channel"; |
| 58 | } |
| 59 | |
| 60 | /** How much of a cap is spent, 0 to 1 (more when over); null with no cap. */ |
| 61 | export function shareOf(spent: number, cap: number | null | undefined): number | null { |
| 62 | if (cap == null || cap <= 0) return null; |
| 63 | return Math.max(0, spent) / cap; |
| 64 | } |
| 65 | |
| 66 | /** A meter's tone: calm, then warning at 75%, then danger at 100%. */ |
| 67 | export function meterTone(share: number | null): "accent" | "warn" | "danger" { |
| 68 | if (share == null || share < 0.75) return "accent"; |
| 69 | return share >= 1 ? "danger" : "warn"; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * A tree of sessions as rows, each child under its parent, children oldest |
| 74 | * first. A session whose parent is not in the list is a root; roots keep the |
| 75 | * list's order. Cycles (which never happen) cannot loop. |
| 76 | */ |
| 77 | export function sessionRows<T extends { id: string; parent_id: string | null; created_at: string }>(sessions: T[]): { session: T; depth: number }[] { |
| 78 | const ids = new Set(sessions.map((s) => s.id)); |
| 79 | const children = new Map<string, T[]>(); |
| 80 | const roots: T[] = []; |
| 81 | for (const session of sessions) { |
| 82 | if (session.parent_id && ids.has(session.parent_id) && session.parent_id !== session.id) { |
| 83 | children.set(session.parent_id, [...(children.get(session.parent_id) ?? []), session]); |
| 84 | } else roots.push(session); |
| 85 | } |
| 86 | const out: { session: T; depth: number }[] = []; |
| 87 | const seen = new Set<string>(); |
| 88 | const walk = (session: T, depth: number) => { |
| 89 | if (seen.has(session.id)) return; |
| 90 | seen.add(session.id); |
| 91 | out.push({ session, depth }); |
| 92 | const kids = [...(children.get(session.id) ?? [])].sort((a, b) => a.created_at.localeCompare(b.created_at)); |
| 93 | for (const kid of kids) walk(kid, depth + 1); |
| 94 | }; |
| 95 | for (const root of roots) walk(root, 0); |
| 96 | // Anything only reachable through a cycle still shows, at the top. |
| 97 | for (const session of sessions) if (!seen.has(session.id)) walk(session, 0); |
| 98 | return out; |
| 99 | } |
| 100 | |
| 101 | const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; |
| 102 | |
| 103 | export function weekdayName(day: number): string { |
| 104 | return WEEKDAYS[((day % 7) + 7) % 7]!; |
| 105 | } |
| 106 | |
| 107 | const two = (n: number) => String(Math.max(0, Math.floor(n))).padStart(2, "0"); |
| 108 | |
| 109 | /** "Every weekday at 09:00 UTC", "Every hour at :15", "Every Monday at 17:30 UTC". */ |
| 110 | export function scheduleInWords(schedule: RoutineSchedule): string { |
| 111 | const time = `${two(schedule.hour)}:${two(schedule.minute)} UTC`; |
| 112 | switch (schedule.every) { |
| 113 | case "hour": |
| 114 | return schedule.minute === 0 ? "Every hour, on the hour" : `Every hour at :${two(schedule.minute)}`; |
| 115 | case "day": |
| 116 | return `Every day at ${time}`; |
| 117 | case "weekday": |
| 118 | return `Every weekday at ${time}`; |
| 119 | case "week": |
| 120 | return `Every ${weekdayName(schedule.weekday)} at ${time}`; |
| 121 | default: |
| 122 | return time; |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /** "A pull request is merged" → "When a pull request is merged". */ |
| 127 | export function whenEvent(label: string): string { |
| 128 | return `When ${label.charAt(0).toLowerCase()}${label.slice(1)}`; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * When a routine runs, in words: its schedule, its events (`labels` names |
| 133 | * each), and the repositories they come from. "Every weekday at 09:00 UTC", |
| 134 | * "When a pull request is ready for review · acme/web". |
| 135 | */ |
| 136 | export function routineInWords( |
| 137 | routine: { schedule: RoutineSchedule | null; events?: readonly string[] | null; repos?: readonly string[] | null }, |
| 138 | labels: Record<string, string>, |
| 139 | ): string { |
| 140 | const parts: string[] = []; |
| 141 | if (routine.schedule) parts.push(scheduleInWords(routine.schedule)); |
| 142 | const events = routine.events ?? []; |
| 143 | if (events.length > 0) { |
| 144 | const named = events.map((key) => labels[key] ?? key); |
| 145 | const first = whenEvent(named[0]!); |
| 146 | const rest = named.slice(1).map((label) => label.charAt(0).toLowerCase() + label.slice(1)); |
| 147 | parts.push([first, ...rest].join(", or ")); |
| 148 | const repos = routine.repos ?? []; |
| 149 | parts.push(repos.length > 0 ? repos.join(", ") : "Any repository"); |
| 150 | } |
| 151 | return parts.length > 0 ? parts.join(" · ") : "Never: it has no schedule or events"; |
| 152 | } |
| 153 | |
| 154 | /** A schedule from the dialog's fields: how often, a time as `HH:MM`, and a weekday. */ |
| 155 | export function scheduleFrom(every: string, time: string, weekday: string | number): RoutineSchedule | null { |
| 156 | if (every !== "hour" && every !== "day" && every !== "weekday" && every !== "week") return null; |
| 157 | const match = /^(\d{1,2}):(\d{2})$/.exec(String(time ?? "").trim()); |
| 158 | const hour = match ? Number(match[1]) : 9; |
| 159 | const minute = match ? Number(match[2]) : 0; |
| 160 | if (hour > 23 || minute > 59) return null; |
| 161 | const day = String(weekday ?? "").trim() === "" ? Number.NaN : Number(weekday); |
| 162 | return { every, hour, minute, weekday: Number.isInteger(day) && day >= 0 && day <= 6 ? day : 1 }; |
| 163 | } |
| 164 | |
| 165 | /** `HH:MM` for a time field. */ |
| 166 | export function timeField(schedule: Pick<RoutineSchedule, "hour" | "minute">): string { |
| 167 | return `${two(schedule.hour)}:${two(schedule.minute)}`; |
| 168 | } |
| 169 | |
| 170 | /** Repositories typed as a list, `acme/web, acme/api`: each `workspace/name`, lowercased, once. Null when one isn't. */ |
| 171 | export function reposFrom(text: string): string[] | null { |
| 172 | const parts = String(text ?? "") |
| 173 | .split(/[\s,]+/) |
| 174 | .map((p) => p.trim().toLowerCase()) |
| 175 | .filter(Boolean); |
| 176 | if (parts.some((p) => !/^[a-z0-9][a-z0-9._-]*\/[a-z0-9._-]+$/.test(p))) return null; |
| 177 | return [...new Set(parts)]; |
| 178 | } |
| 179 | |
| 180 | export type MemoryGroup = { scope: AgentMemoryScope; title: string; about: string; memories: AgentMemory[] }; |
| 181 | |
| 182 | /** |
| 183 | * Memories by scope, in the order the page shows them, pinned first in each. |
| 184 | * Empty groups are left out. |
| 185 | */ |
| 186 | export function memoryGroups(memories: AgentMemory[]): MemoryGroup[] { |
| 187 | const order = (a: AgentMemory, b: AgentMemory) => Number(b.pinned) - Number(a.pinned) || b.updated_at.localeCompare(a.updated_at); |
| 188 | const groups: MemoryGroup[] = [ |
| 189 | { scope: "workspace", title: "Workspace", about: "Recalled anywhere in the workspace, for anyone.", memories: [] }, |
| 190 | { scope: "channel", title: "Conversations", about: "Recalled only in the channel each came from.", memories: [] }, |
| 191 | { scope: "person", title: "Just you", about: "Recalled only in your direct messages with it.", memories: [] }, |
| 192 | ]; |
| 193 | for (const memory of memories) groups.find((g) => g.scope === memory.scope)?.memories.push(memory); |
| 194 | return groups.filter((g) => g.memories.length > 0).map((g) => ({ ...g, memories: [...g.memories].sort(order) })); |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Every day of a month (`YYYY-MM`) up to today when it is this month, with |
| 199 | * its spend; days with none are 0. |
| 200 | */ |
| 201 | export function monthDays(period: string, days: { day: string; micros: number }[], now = new Date()): { day: string; micros: number }[] { |
| 202 | const match = /^(\d{4})-(\d{2})$/.exec(period); |
| 203 | if (!match) return days; |
| 204 | const year = Number(match[1]); |
| 205 | const month = Number(match[2]) - 1; |
| 206 | const inMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); |
| 207 | const thisMonth = now.getUTCFullYear() === year && now.getUTCMonth() === month; |
| 208 | const last = thisMonth ? now.getUTCDate() : inMonth; |
| 209 | const spent = new Map(days.map((d) => [d.day, d.micros])); |
| 210 | const out: { day: string; micros: number }[] = []; |
| 211 | for (let d = 1; d <= last; d++) { |
| 212 | const key = `${period}-${two(d)}`; |
| 213 | out.push({ day: key, micros: spent.get(key) ?? 0 }); |
| 214 | } |
| 215 | return out; |
| 216 | } |
| 217 | |
| 218 | /** The first `n` slices, and the rest folded into one "Everything else". */ |
| 219 | export function topSlices(slices: SpendSlice[], n = 5): SpendSlice[] { |
| 220 | if (slices.length <= n + 1) return slices; |
| 221 | const rest = slices.slice(n); |
| 222 | return [ |
| 223 | ...slices.slice(0, n), |
| 224 | { |
| 225 | key: "__rest", |
| 226 | label: `${rest.length} more`, |
| 227 | micros: rest.reduce((sum, s) => sum + s.micros, 0), |
| 228 | count: rest.reduce((sum, s) => sum + s.count, 0), |
| 229 | }, |
| 230 | ]; |
| 231 | } |
| 232 | |
| 233 | /** "in 5m", "in 3h", "in 2d"; "now" when due. */ |
| 234 | export function untilLabel(at: string, now = Date.now()): string { |
| 235 | const ms = new Date(at).getTime() - now; |
| 236 | if (!Number.isFinite(ms) || ms <= 60_000) return "now"; |
| 237 | if (ms < 3_600_000) return `in ${Math.round(ms / 60_000)}m`; |
| 238 | if (ms < 86_400_000) return `in ${Math.round(ms / 3_600_000)}h`; |
| 239 | return `in ${Math.round(ms / 86_400_000)}d`; |
| 240 | } |
| 241 | |
| 242 | /** A short day for an axis or a hint: "Oct 3". */ |
| 243 | export function shortDay(day: string): string { |
| 244 | const at = new Date(`${day}T00:00:00Z`); |
| 245 | if (Number.isNaN(at.getTime())) return day; |
| 246 | return at.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); |
| 247 | } |
| 248 | |
| 249 | /** A month key as words: "October 2026". */ |
| 250 | export function monthName(period: string): string { |
| 251 | const at = new Date(`${period}-01T00:00:00Z`); |
| 252 | if (Number.isNaN(at.getTime())) return period; |
| 253 | return at.toLocaleDateString("en-US", { month: "long", year: "numeric", timeZone: "UTC" }); |
| 254 | } |
| 255 | |
| 256 | const FIELD_LABELS: Record<string, string> = { |
| 257 | display_name: "name", |
| 258 | handle: "handle", |
| 259 | title: "title", |
| 260 | team: "team", |
| 261 | department: "department", |
| 262 | role: "role", |
| 263 | responsibilities: "responsibilities", |
| 264 | subagents: "subagents", |
| 265 | instructions: "job", |
| 266 | personality_preset: "personality", |
| 267 | personality: "personality", |
| 268 | routing: "models", |
| 269 | budget: "budget", |
| 270 | autonomy: "what it may do alone", |
| 271 | capacity: "capacity", |
| 272 | avatar_seed: "face", |
| 273 | faces: "who it works with", |
| 274 | skills_off: "skills", |
| 275 | }; |
| 276 | |
| 277 | /** |
| 278 | * What changed between two saved versions of an agent, in words: "job, |
| 279 | * budget". The first version (no `before`) is "Created". |
| 280 | */ |
| 281 | export function versionChanges(before: Record<string, unknown> | null, after: Record<string, unknown>): string { |
| 282 | if (!before) return "Created"; |
| 283 | const keys = new Set([...Object.keys(before), ...Object.keys(after)]); |
| 284 | const changed: string[] = []; |
| 285 | for (const key of keys) { |
| 286 | // A list saved empty reads the same as one an older version didn't have. |
| 287 | const empty = (value: unknown) => value == null || (Array.isArray(value) && value.length === 0); |
| 288 | if (JSON.stringify(before[key] ?? null) === JSON.stringify(after[key] ?? null) || (empty(before[key]) && empty(after[key]))) continue; |
| 289 | const label = FIELD_LABELS[key]; |
| 290 | if (label && !changed.includes(label)) changed.push(label); |
| 291 | } |
| 292 | if (changed.length === 0) return "Saved with no changes"; |
| 293 | const text = changed.join(", "); |
| 294 | return `Changed ${text}`; |
| 295 | } |