Skip to content
292 linesCodeBlameRaw
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 */
7import type {
8 AgentMemory,
9 AgentMemoryScope,
10 AgentSessionKind,
11 AgentSessionStatus,
12 RoutineSchedule,
13 SpendSlice,
14} from "@g1t/contracts";
15
16export type Tone = "neutral" | "accent" | "info" | "warn" | "success" | "danger";
17
18/** The statuses of a session that is not over (the contract's `SESSION_LIVE`). */
19export const LIVE_STATUSES: readonly AgentSessionStatus[] = ["queued", "working", "waiting", "needs_approval"];
20
21export 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. */
26export 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
47const KINDS: Record<AgentSessionKind, string> = { chat: "Chat", routine: "Routine", helper: "Helper", subagent: "Subagent" };
48
49/** Where a session came from, in a word. */
50export 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. */
55export 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. */
61export 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%. */
67export 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 */
77export 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
101const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
102
103export function weekdayName(day: number): string {
104 return WEEKDAYS[((day % 7) + 7) % 7]!;
105}
106
107const 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". */
110export 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". */
127export 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 */
136export 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. */
155export 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. */
166export 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. */
171export 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
180export 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 */
186export 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 */
201export 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". */
219export 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. */
234export 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". */
243export 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". */
250export 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
256const 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};
275
276/**
277 * What changed between two saved versions of an agent, in words: "job,
278 * budget". The first version (no `before`) is "Created".
279 */
280export function versionChanges(before: Record<string, unknown> | null, after: Record<string, unknown>): string {
281 if (!before) return "Created";
282 const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
283 const changed: string[] = [];
284 for (const key of keys) {
285 if (JSON.stringify(before[key] ?? null) === JSON.stringify(after[key] ?? null)) continue;
286 const label = FIELD_LABELS[key];
287 if (label && !changed.includes(label)) changed.push(label);
288 }
289 if (changed.length === 0) return "Saved with no changes";
290 const text = changed.join(", ");
291 return `Changed ${text}`;
292}