| 1 | /** |
| 2 | * Budgets per person (docs.g1t.sh/guides/agent-budgets/): what the agents |
| 3 | * working for one person, the replies and sessions that person asked for, |
| 4 | * may spend together in a month. The default is the workspace policy's |
| 5 | * `person_monthly_micros`; an owner can give anyone their own |
| 6 | * (`person_budgets`, 0 for none at all). What a person's agents spent is |
| 7 | * summed from the replies and the root sessions they asked for, the same |
| 8 | * rows the spend breakdown reads, so the two always agree. |
| 9 | */ |
| 10 | import type { PersonBudget, PersonBudgets } from "@g1t/contracts"; |
| 11 | |
| 12 | import { monthKey, personLimit } from "./budget.ts"; |
| 13 | |
| 14 | /** The first instant of `now`'s month (UTC), as rows' `created_at` compare. */ |
| 15 | export function monthStart(now: Date): string { |
| 16 | return `${monthKey(now)}-01T00:00:00.000Z`; |
| 17 | } |
| 18 | |
| 19 | /** What agents spent for `username` since `from`: their replies, and their sessions with everything each brought in. */ |
| 20 | export async function personSpent(db: D1Database, workspaceId: string, username: string, from: string): Promise<number> { |
| 21 | const row = await db |
| 22 | .prepare( |
| 23 | `SELECT |
| 24 | (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_replies WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3) |
| 25 | + (SELECT COALESCE(SUM(charged_micros), 0) FROM agent_sessions WHERE workspace_id = ?1 AND asked_by_username = ?2 AND created_at >= ?3 AND parent_id IS NULL) |
| 26 | AS micros`, |
| 27 | ) |
| 28 | .bind(workspaceId, username, from) |
| 29 | .first<{ micros: number | null }>(); |
| 30 | return row?.micros ?? 0; |
| 31 | } |
| 32 | |
| 33 | /** A person's own budget, if an owner set one (0: none at all). */ |
| 34 | export async function ownBudget(db: D1Database, workspaceId: string, username: string): Promise<number | null> { |
| 35 | const row = await db |
| 36 | .prepare("SELECT monthly_micros FROM person_budgets WHERE workspace_id = ? AND username = ?") |
| 37 | .bind(workspaceId, username) |
| 38 | .first<{ monthly_micros: number }>(); |
| 39 | return row ? row.monthly_micros : null; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Everyone's budget this month: each person with one of their own, and |
| 44 | * each person agents spent for. `only` narrows it to one person. |
| 45 | */ |
| 46 | export async function personBudgets(db: D1Database, workspaceId: string, defaultMicros: number | null, now: Date, only: string | null): Promise<PersonBudgets> { |
| 47 | const from = monthStart(now); |
| 48 | const narrow = only ? " AND asked_by_username = ?3" : ""; |
| 49 | const binds = only ? [workspaceId, from, only] : [workspaceId, from]; |
| 50 | const [own, spent] = await Promise.all([ |
| 51 | db |
| 52 | .prepare(`SELECT username, monthly_micros FROM person_budgets WHERE workspace_id = ?1${only ? " AND username = ?2" : ""}`) |
| 53 | .bind(...(only ? [workspaceId, only] : [workspaceId])) |
| 54 | .all<{ username: string; monthly_micros: number }>(), |
| 55 | db |
| 56 | .prepare( |
| 57 | `SELECT asked_by_username AS username, SUM(micros) AS micros FROM ( |
| 58 | SELECT asked_by_username, charged_micros AS micros FROM agent_replies WHERE workspace_id = ?1 AND created_at >= ?2 AND asked_by_username IS NOT NULL${narrow} |
| 59 | UNION ALL SELECT asked_by_username, charged_micros AS micros FROM agent_sessions WHERE workspace_id = ?1 AND created_at >= ?2 AND parent_id IS NULL AND asked_by_username IS NOT NULL${narrow} |
| 60 | ) GROUP BY asked_by_username`, |
| 61 | ) |
| 62 | .bind(...binds) |
| 63 | .all<{ username: string; micros: number }>(), |
| 64 | ]); |
| 65 | const people = new Map<string, PersonBudget>(); |
| 66 | const entry = (username: string) => { |
| 67 | const found = people.get(username); |
| 68 | if (found) return found; |
| 69 | const fresh: PersonBudget = { username, monthly_micros: personLimit(defaultMicros, null), own: false, spent_micros: 0 }; |
| 70 | people.set(username, fresh); |
| 71 | return fresh; |
| 72 | }; |
| 73 | for (const row of own.results) { |
| 74 | const person = entry(row.username); |
| 75 | person.own = true; |
| 76 | person.monthly_micros = personLimit(defaultMicros, row.monthly_micros); |
| 77 | } |
| 78 | for (const row of spent.results) entry(row.username).spent_micros = row.micros ?? 0; |
| 79 | if (only) entry(only); |
| 80 | return { |
| 81 | period: monthKey(now), |
| 82 | default_micros: personLimit(defaultMicros, null), |
| 83 | people: [...people.values()].sort((a, b) => b.spent_micros - a.spent_micros || a.username.localeCompare(b.username)), |
| 84 | }; |
| 85 | } |
| 86 | |
| 87 | /** A username as people type it: `@Ana` is `ana`. Null when it can't be one. */ |
| 88 | export function cleanUsername(raw: unknown): string | null { |
| 89 | const name = String(raw ?? "").trim().replace(/^@/, "").toLowerCase(); |
| 90 | return /^[a-z0-9][a-z0-9_-]{0,38}$/.test(name) ? name : null; |
| 91 | } |