| 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 | */ |
| 13 | import type { AgentTeam, PresenceEntry } from "@g1t/contracts"; |
| 14 | |
| 15 | import { dollars } from "./money.ts"; |
| 16 | |
| 17 | /** An agent as its teammates are told of it. */ |
| 18 | export type TeamAgentInfo = { id: string; handle: string; display_name: string; title: string }; |
| 19 | |
| 20 | /** Everything about an agent's teams that one turn needs. */ |
| 21 | export 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. */ |
| 30 | export function agentIdsOn(team: AgentTeam): string[] { |
| 31 | return [...new Set(team.agent_ids)]; |
| 32 | } |
| 33 | |
| 34 | /** "QA", "QA and Web", "QA, Web and Support". */ |
| 35 | export 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. */ |
| 41 | export 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. */ |
| 51 | export 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. */ |
| 67 | function 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 | |
| 72 | function 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 | */ |
| 81 | export 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. */ |
| 89 | export 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. */ |
| 147 | export 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. */ |
| 150 | export 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`). */ |
| 157 | export 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 | } |