Skip to content
169 linesCodeBlameRaw
1/**
2 * Agents know their teammates (docs.g1t.sh/guides/people-and-teams/,
3 * "What agents are told"): every turn, an agent is told the visible teams
4 * it is on, from the team pages: who leads each, who is on it and what
5 * they own, who reports to whom, who is around now, and who to page when
6 * a person is needed. It also carries the teams' budgets, which cap what
7 * the team's agents spend together in a month.
8 *
9 * `teamsSection` and `teamBudgetBlock` are pure, so they are tested on
10 * their own (teammates.test.ts); `loadTeams` (ports.ts) reads identity,
11 * the presence rooms (notify) and this service's own agents.
12 */
13import type { AgentTeam, PresenceEntry } from "@g1t/contracts";
14
15import { dollars } from "./money.ts";
16
17/** An agent as its teammates are told of it. */
18export type TeamAgentInfo = { id: string; handle: string; display_name: string; title: string };
19
20/** Everything about an agent's teams that one turn needs. */
21export type TeamsHere = {
22 teams: AgentTeam[];
23 /** Every agent on those teams, by id. */
24 agents: TeamAgentInfo[];
25 /** How the people on those teams show now; someone missing is offline. */
26 presence: PresenceEntry[];
27};
28
29/** The agents on `team`, by id: its members, as the team page adds them. */
30export function agentIdsOn(team: AgentTeam): string[] {
31 return [...new Set(team.agent_ids)];
32}
33
34/** "QA", "QA and Web", "QA, Web and Support". */
35export function listOf(names: readonly string[]): string {
36 if (names.length < 2) return names.join("");
37 return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
38}
39
40/** "18:12", their local time, or null when the zone isn't one. */
41export function localTime(zone: string | null, now: Date): string | null {
42 if (!zone) return null;
43 try {
44 return new Intl.DateTimeFormat("en-GB", { timeZone: zone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(now);
45 } catch {
46 return null;
47 }
48}
49
50/** How someone shows now, in a few words; null when presence wasn't read. */
51export function showing(entry: PresenceEntry | null | undefined, zone: string | null, now: Date, known: boolean): string | null {
52 if (!known) return null;
53 if (!entry || entry.presence === "offline") return "offline";
54 const parts: string[] = [];
55 const dnd = entry.dnd_until && Date.parse(entry.dnd_until) > now.getTime() ? entry.dnd_until : null;
56 if (dnd) {
57 const until = localTime(zone, new Date(dnd));
58 parts.push(`focusing in Do Not Disturb${until ? ` until ${until} their time` : ""}`);
59 } else {
60 parts.push(entry.presence === "active" ? "online" : "away");
61 }
62 if (entry.status?.text) parts.push(`status "${[entry.status.emoji, entry.status.text].filter(Boolean).join(" ")}"`);
63 return parts.join("; ");
64}
65
66/** Whether someone can be reached now: online and not in Do Not Disturb. */
67function reachable(entry: PresenceEntry | null | undefined, now: Date): boolean {
68 if (!entry || entry.presence !== "active") return false;
69 return !(entry.dnd_until && Date.parse(entry.dnd_until) > now.getTime());
70}
71
72function personLabel(person: { username: string; name: string | null }): string {
73 return person.name && person.name.toLowerCase() !== person.username.toLowerCase() ? `${person.name} (@${person.username})` : `@${person.username}`;
74}
75
76/**
77 * Who to page on a team when a person is needed: its lead when they can
78 * be reached, else a maintainer, else anyone on it who can. Null when
79 * no one can, or presence wasn't read.
80 */
81export function whoToPage(team: AgentTeam, presence: Map<string, PresenceEntry>, now: Date, known: boolean): AgentTeam["people"][number] | null {
82 if (!known) return null;
83 const lead = team.lead?.kind === "user" ? team.people.find((p) => p.username === (team.lead as { username: string }).username) : undefined;
84 const order = [...(lead ? [lead] : []), ...team.people.filter((p) => p.maintainer && p !== lead), ...team.people.filter((p) => !p.maintainer && p !== lead)];
85 return order.find((p) => reachable(presence.get(p.user_id), now)) ?? null;
86}
87
88/** "Your teams", for the system prompt; null when the agent is on none. */
89export function teamsSection(selfId: string, here: TeamsHere | null, now: Date): string | null {
90 if (!here || !here.teams.length) return null;
91 const known = here.presence.length > 0;
92 const presence = new Map(here.presence.map((entry) => [entry.user_id, entry]));
93 const byId = new Map(here.agents.map((agent) => [agent.id, agent]));
94 const blocks = here.teams.map((team) => {
95 const agentIds = agentIdsOn(team);
96 const agents = agentIds.map((id) => byId.get(id)).filter((a): a is TeamAgentInfo => !!a);
97 const facts: string[] = [];
98 if (team.description) facts.push(team.description.replace(/\s+/g, " ").trim().replace(/\.?$/, "."));
99 if (team.lead?.kind === "user") {
100 const username = team.lead.username;
101 const lead = team.people.find((p) => p.username === username);
102 facts.push(`Led by ${lead ? personLabel(lead) : `@${username}`}.`);
103 } else if (team.lead?.kind === "agent") {
104 const id = team.lead.agent_id;
105 const lead = byId.get(id);
106 facts.push(id === selfId ? "You lead it." : lead ? `Led by @${lead.handle}, an agent.` : "Led by an agent.");
107 }
108 if (team.channel) facts.push(`Its channel is #${team.channel.name}.`);
109 if (team.budget_micros) facts.push(`Its agents share a budget of ${dollars(team.budget_micros)} a month.`);
110 const lines = team.people.map((person) => {
111 const bits = [personLabel(person)];
112 const role = [person.title, team.lead?.kind === "user" && team.lead.username === person.username ? "the lead" : null, person.maintainer ? "a maintainer" : null].filter(Boolean);
113 if (role.length) bits.push(role.join(", "));
114 if (person.owns.length) bits.push(`owns ${person.owns.join(", ")}`);
115 if (person.manager) bits.push(`reports to @${person.manager}`);
116 const shows = showing(presence.get(person.user_id), person.timezone, now, known);
117 const time = localTime(person.timezone, now);
118 const now_ = [shows, time ? `it's ${time} for them` : null].filter(Boolean).join("; ");
119 return `- ${bits.join("; ")}${now_ ? ` (${now_})` : ""}`;
120 });
121 for (const agent of agents) {
122 lines.push(agent.id === selfId ? `- @${agent.handle}: you` : `- @${agent.handle} (${agent.display_name}), an agent${agent.title ? `: ${agent.title}` : ""}`);
123 }
124 if (!lines.length) lines.push("- No one else yet.");
125 const page = whoToPage(team, presence, now, known);
126 const pageLine = !known
127 ? null
128 : page
129 ? `When a person is needed: ${personLabel(page)}, who is online now.`
130 : team.people.length
131 ? `No one on ${team.name} can be reached now; ${team.channel ? `post in #${team.channel.name} and ` : ""}say they'll see it when they're back.`
132 : null;
133 return [`### ${team.name}`, "", ...(facts.length ? [facts.join(" "), ""] : []), ...lines, ...(pageLine ? ["", pageLine] : [])].join("\n");
134 });
135 return [
136 "## Your teams",
137 "",
138 "From your teams' pages, and how people show right now. Use it to know who leads, who owns what and who to ask or hand work to. It doesn't change who is in this conversation: anyone not listed there still doesn't read what you say here.",
139 "",
140 "- Ask the person who owns something before guessing. Hold non-urgent questions for someone focusing or away, and say so.",
141 "",
142 blocks.join("\n\n"),
143 ].join("\n");
144}
145
146/** One team's month so far: its budget and what its agents spent together. */
147export type TeamSpend = { name: string; budget_micros: number; spent_micros: number };
148
149/** Why work may not start: a team the agent is on has used its budget for the month. Null when it may. */
150export function teamBudgetBlock(spends: readonly TeamSpend[]): string | null {
151 const over = spends.find((team) => team.budget_micros > 0 && team.spent_micros >= team.budget_micros);
152 if (!over) return null;
153 return `${over.name}'s agents have used the team's budget of ${dollars(over.budget_micros)} for this month. Someone who manages the team can raise it on its settings.`;
154}
155
156/** Each team with a budget, with what its agents spent this month (`month` is `YYYY-MM`). */
157export async function teamSpends(db: D1Database, here: TeamsHere, month: string): Promise<TeamSpend[]> {
158 const budgeted = here.teams.filter((team) => team.budget_micros && team.budget_micros > 0);
159 return Promise.all(
160 budgeted.map(async (team) => {
161 const ids = agentIdsOn(team);
162 const row = await db
163 .prepare("SELECT COALESCE(SUM(micros), 0) AS micros FROM agent_spend WHERE period = ?1 AND agent_id IN (SELECT value FROM json_each(?2))")
164 .bind(month, JSON.stringify(ids))
165 .first<{ micros: number | null }>();
166 return { name: team.name, budget_micros: team.budget_micros!, spent_micros: row?.micros ?? 0 };
167 }),
168 );
169}