Skip to content
91 linesCodeBlameRaw
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 */
10import type { PersonBudget, PersonBudgets } from "@g1t/contracts";
11
12import { monthKey, personLimit } from "./budget.ts";
13
14/** The first instant of `now`'s month (UTC), as rows' `created_at` compare. */
15export 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. */
20export 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). */
34export 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 */
46export 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. */
88export 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}