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