Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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