Skip to content
686 linesCodeBlameRaw
1/**
2 * What Agents mode reads and changes beyond definitions: sessions, memory,
3 * routines, spend, activity, versions and the workspace's agent policy.
4 * Called by the RPC methods in index.ts once they know who is asking and
5 * that they may see the workspace.
6 *
7 * Privacy follows the conversations work came from. A session, a reply or
8 * a memory from a conversation the viewer is not in shows that it happened
9 * and what it cost, never what it was about, owners included: owners
10 * control money and agents, not other people's conversations.
11 */
12import {
13 type AgentActivity,
14 type AgentMemory,
15 type AgentPolicy,
16 type AgentRoutine,
17 type AgentSession,
18 type AgentSessionDetail,
19 type AgentTeamContext,
20 type AgentSpendBreakdown,
21 type AgentVersion,
22 type AgentsOverview,
23 type AgentMemoryScope,
24 type NewRoutine,
25 type PersonBudgets,
26 type Result,
27 type RoutineSuggestion,
28 type SessionEvent,
29 type SpendSlice,
30 type User,
31 type WorkspaceAgent,
32 chatClient,
33 fail,
34 newId,
35 ok,
36} from "@g1t/contracts";
37
38import { monthKey, spendSpan } from "./budget.ts";
39import { cleanUsername, personBudgets } from "./person-budget.ts";
40import { type MemoryRow, type MemoryViewer, changeableBy, cleanFact, toMemory, visibleTo } from "./memory.ts";
41import { DEFAULT_POLICY, checkPolicy, readPolicy } from "./policy.ts";
42import { type RoutineRow, MAX_ROUTINES, checkRoutine, newRoutineId, nextRun, runRoutine, toRoutine } from "./routines.ts";
43import { type SessionEnv, type SessionRow, LIVE, approve, sessionRow, steer, stop, toSession } from "./sessions.ts";
44import { type Row, definitionOf, periods, selectAgents, toAgent } from "./store.ts";
45import { suggestRoutines } from "./suggest.ts";
46import { teamsSection } from "./teammates.ts";
47import { loadTeams } from "./ports.ts";
48
49export type ViewContext = {
50 env: SessionEnv;
51 db: D1Database;
52 slug: string;
53 workspaceId: string;
54 viewer: User;
55 /** Whether the viewer owns the workspace (or is its token). */
56 owner: boolean;
57};
58
59type AgentFace = { handle: string; display_name: string; avatar_seed: string; team: string | null; department: string };
60
61/** The workspace's agents by id, archived ones too, for names on sessions and spend. */
62async function faces(ctx: ViewContext): Promise<Map<string, AgentFace>> {
63 const rows = await ctx.db
64 .prepare("SELECT id, handle, display_name, avatar_seed, team, department FROM agents WHERE workspace_id = ?")
65 .bind(ctx.workspaceId)
66 .all<{ id: string } & AgentFace>();
67 return new Map(rows.results.map((r) => [r.id, { ...r, avatar_seed: r.avatar_seed || r.handle }]));
68}
69
70/**
71 * Which of these conversations the viewer is in (or can read: a public
72 * channel). Asked of chat once per conversation, at most 60.
73 */
74async function readable(ctx: ViewContext, channelIds: string[]): Promise<Set<string>> {
75 const out = new Set<string>();
76 if (ctx.viewer.kind === "workspace") return out;
77 const chat = chatClient(ctx.env.CHAT);
78 const ids = [...new Set(channelIds)].slice(0, 60);
79 await Promise.all(
80 ids.map(async (id) => {
81 const audience = await chat.audience(ctx.slug, id).catch(() => null);
82 if (audience?.ok && (audience.value.kind === "public" || audience.value.member_user_ids.includes(ctx.viewer.id))) out.add(id);
83 }),
84 );
85 return out;
86}
87
88async function sessionsOut(ctx: ViewContext, rows: SessionRow[], agents?: Map<string, AgentFace>): Promise<AgentSession[]> {
89 const names = agents ?? (await faces(ctx));
90 const can = await readable(ctx, rows.map((r) => r.channel_id));
91 return rows.map((row) => toSession(row, names.get(row.agent_id) ?? null, can.has(row.channel_id)));
92}
93
94async function agentByHandle(ctx: ViewContext, handle: string): Promise<Row | null> {
95 return ctx.db
96 .prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
97 .bind(ctx.workspaceId, String(handle ?? "").trim().replace(/^@/, "").toLowerCase())
98 .first<Row>();
99}
100
101// ── Teams ─────────────────────────────────────────────────────────────────
102
103/**
104 * What an agent is told about its teams this turn, word for word as it
105 * gets it (teammates.ts), for its People profile and its teams' pages.
106 * Null when it is on no visible team.
107 */
108export async function teamContext(ctx: ViewContext, handle: string): Promise<Result<AgentTeamContext>> {
109 const agent = await agentByHandle(ctx, handle);
110 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
111 const here = await loadTeams(ctx.env, ctx.slug, ctx.workspaceId, { id: agent.id, team: agent.team ?? null });
112 if (!here) return fail("not_found", "Its teams could not be read just now.");
113 return ok({ handle: agent.handle, teams: here.teams.map((team) => team.slug), text: teamsSection(agent.id, here, new Date()) });
114}
115
116// ── Sessions ──────────────────────────────────────────────────────────────
117
118export async function listSessions(ctx: ViewContext, filter: { handle?: string | null; status?: "live" | "done" | null; limit?: number | null }): Promise<Result<AgentSession[]>> {
119 const limit = Math.min(200, Math.max(1, Math.floor(Number(filter.limit) || 50)));
120 const where = ["workspace_id = ?"];
121 const binds: (string | number)[] = [ctx.workspaceId];
122 if (filter.handle) {
123 const agent = await agentByHandle(ctx, filter.handle);
124 if (!agent) return fail("not_found", `There is no agent called @${filter.handle}.`);
125 where.push("agent_id = ?");
126 binds.push(agent.id);
127 }
128 if (filter.status === "live") where.push(`status IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
129 if (filter.status === "done") where.push(`status NOT IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
130 const rows = await ctx.db
131 .prepare(`SELECT * FROM agent_sessions WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`)
132 .bind(...binds, limit)
133 .all<SessionRow>();
134 return ok(await sessionsOut(ctx, rows.results));
135}
136
137export async function sessionDetail(ctx: ViewContext, id: string): Promise<Result<AgentSessionDetail>> {
138 const row = await sessionRow(ctx.db, String(id ?? ""));
139 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
140 const agents = await faces(ctx);
141 const treeRows = await ctx.db.prepare("SELECT * FROM agent_sessions WHERE root_id = ? ORDER BY created_at LIMIT 100").bind(row.root_id).all<SessionRow>();
142 const [session] = await sessionsOut(ctx, [row], agents);
143 const tree = await sessionsOut(ctx, treeRows.results, agents);
144 let events: SessionEvent[] = [];
145 if (session.visible) {
146 const rows = await ctx.db
147 .prepare("SELECT seq, kind, by_name, body, tool, outcome, created_at FROM agent_session_events WHERE session_id = ? ORDER BY seq LIMIT 1000")
148 .bind(row.id)
149 .all<{ seq: number; kind: SessionEvent["kind"]; by_name: string | null; body: string; tool: string | null; outcome: string | null; created_at: string }>();
150 events = rows.results.map((e) => ({ seq: e.seq, kind: e.kind, by: e.by_name, body: e.body, tool: e.tool, outcome: e.outcome, created_at: e.created_at }));
151 }
152 const live = LIVE.includes(row.status as (typeof LIVE)[number]);
153 return ok({
154 session,
155 events,
156 tree,
157 can_stop: session.visible && live,
158 can_steer: session.visible,
159 can_approve: row.status === "needs_approval" && (ctx.owner || (session.visible && row.asked_by === ctx.viewer.id && ctx.owner)),
160 });
161}
162
163async function visibleSession(ctx: ViewContext, id: string): Promise<Result<SessionRow>> {
164 const row = await sessionRow(ctx.db, String(id ?? ""));
165 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
166 const can = await readable(ctx, [row.channel_id]);
167 if (!can.has(row.channel_id)) return fail("not_found", "There is no such session.");
168 return ok(row);
169}
170
171export async function stopSession(ctx: ViewContext, id: string): Promise<Result<AgentSession>> {
172 const found = await visibleSession(ctx, id);
173 if (!found.ok) return found;
174 if (!LIVE.includes(found.value.status as (typeof LIVE)[number])) return fail("invalid", "That session is already over.");
175 const row = await stop(ctx.env, found.value, ctx.viewer.username);
176 return ok((await sessionsOut(ctx, [row]))[0]);
177}
178
179export async function steerSession(ctx: ViewContext, id: string, body: string): Promise<Result<AgentSession>> {
180 const found = await visibleSession(ctx, id);
181 if (!found.ok) return found;
182 const text = typeof body === "string" ? body.trim() : "";
183 if (!text) return fail("invalid", "Say something to the session.");
184 const row = await steer(ctx.env, found.value, ctx.viewer.username, text.slice(0, 4000));
185 return ok((await sessionsOut(ctx, [row]))[0]);
186}
187
188/** Owners raise a session's cap; it must end up above what it has spent. */
189export async function approveSession(ctx: ViewContext, id: string, capMicros: number): Promise<Result<AgentSession>> {
190 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners can approve more spend.");
191 const row = await sessionRow(ctx.db, String(id ?? ""));
192 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
193 if (row.status !== "needs_approval") return fail("invalid", "That session isn't waiting for approval.");
194 const cap = Math.floor(Number(capMicros));
195 if (!Number.isFinite(cap) || cap <= row.charged_micros || cap > 1_000_000_000) return fail("invalid", "The new cap must be above what it has spent.");
196 const fresh = await approve(ctx.env, row, ctx.viewer.username, cap);
197 return ok((await sessionsOut(ctx, [fresh]))[0]);
198}
199
200// ── Memory ────────────────────────────────────────────────────────────────
201
202async function memoryViewer(ctx: ViewContext, rows: MemoryRow[]): Promise<MemoryViewer> {
203 const channels = await readable(ctx, rows.filter((r) => r.scope === "channel").map((r) => r.scope_ref));
204 return { id: ctx.viewer.id, owner: ctx.owner, inChannel: (id) => channels.has(id) };
205}
206
207export async function memories(ctx: ViewContext, handle: string): Promise<Result<AgentMemory[]>> {
208 const agent = await agentByHandle(ctx, handle);
209 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
210 const rows = await ctx.db.prepare("SELECT * FROM agent_memories WHERE agent_id = ? ORDER BY pinned DESC, updated_at DESC LIMIT 500").bind(agent.id).all<MemoryRow>();
211 const viewer = await memoryViewer(ctx, rows.results);
212 return ok(rows.results.filter((row) => visibleTo(row, viewer)).map(toMemory));
213}
214
215/**
216 * A fact a person gives an agent. Workspace facts are the owners'; a
217 * channel fact needs the person to be in that channel; a person fact is
218 * always their own.
219 */
220export async function remember(ctx: ViewContext, handle: string, input: { body: string; scope: AgentMemoryScope; scope_ref?: string | null }): Promise<Result<AgentMemory>> {
221 const agent = await agentByHandle(ctx, handle);
222 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
223 const body = cleanFact(input?.body);
224 if (!body) return fail("invalid", "Say what it should remember.");
225 let scope: AgentMemoryScope = input?.scope === "workspace" || input?.scope === "channel" ? input.scope : "person";
226 let ref = "";
227 let label: string | null = null;
228 if (scope === "workspace" && !ctx.owner) return fail("forbidden", "Only owners give an agent facts for the whole workspace.");
229 if (scope === "channel") {
230 ref = String(input.scope_ref ?? "");
231 const can = await readable(ctx, [ref]);
232 if (!can.has(ref)) return fail("forbidden", "You can only give it facts for conversations you're in.");
233 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, ref).catch(() => null);
234 label = audience?.ok && "name" in audience.value ? ((audience.value as { name?: string | null }).name ?? null) : null;
235 }
236 if (scope === "person") {
237 scope = "person";
238 ref = ctx.viewer.id;
239 label = ctx.viewer.username;
240 }
241 const id = newId("mem");
242 const now = new Date().toISOString();
243 await ctx.db
244 .prepare(
245 `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, scope_label, body, source_kind, source_ref, source_label, created_by, created_by_kind, pinned, created_at, updated_at)
246 VALUES (?, ?, ?, ?, ?, ?, ?, 'person', ?, ?, ?, 'user', 1, ?, ?)`,
247 )
248 .bind(id, agent.id, ctx.workspaceId, scope, ref, label, body, ctx.viewer.username, `@${ctx.viewer.username}`, ctx.viewer.username, now, now)
249 .run();
250 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(id).first<MemoryRow>();
251 return ok(toMemory(row!));
252}
253
254async function changeable(ctx: ViewContext, handle: string, id: string): Promise<Result<MemoryRow>> {
255 const agent = await agentByHandle(ctx, handle);
256 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
257 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<MemoryRow>();
258 if (!row) return fail("not_found", "There is no such memory.");
259 const viewer = await memoryViewer(ctx, [row]);
260 if (!visibleTo(row, viewer)) return fail("not_found", "There is no such memory.");
261 if (!changeableBy(row, viewer)) return fail("forbidden", "Only owners change what an agent knows for the whole workspace.");
262 return ok(row);
263}
264
265export async function updateMemory(ctx: ViewContext, handle: string, id: string, changes: { body?: string; pinned?: boolean }): Promise<Result<AgentMemory>> {
266 const found = await changeable(ctx, handle, id);
267 if (!found.ok) return found;
268 const body = changes?.body === undefined ? found.value.body : cleanFact(changes.body);
269 if (!body) return fail("invalid", "A memory can't be empty; forget it instead.");
270 const pinned = changes?.pinned === undefined ? found.value.pinned : changes.pinned ? 1 : 0;
271 const now = new Date().toISOString();
272 const edited = body !== found.value.body;
273 await ctx.db
274 .prepare(
275 `UPDATE agent_memories SET body = ?, pinned = ?, updated_at = ?${edited ? ", source_kind = 'person', source_ref = ?, source_label = ?" : ""} WHERE id = ?`,
276 )
277 .bind(...(edited ? [body, pinned, now, ctx.viewer.username, `@${ctx.viewer.username} (corrected)`, found.value.id] : [body, pinned, now, found.value.id]))
278 .run();
279 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(found.value.id).first<MemoryRow>();
280 return ok(toMemory(row!));
281}
282
283export async function forget(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
284 const found = await changeable(ctx, handle, id);
285 if (!found.ok) return found;
286 await ctx.db.prepare("DELETE FROM agent_memories WHERE id = ?").bind(found.value.id).run();
287 return ok(null);
288}
289
290// ── Routines ──────────────────────────────────────────────────────────────
291
292/** An agent's routines, and routines its responsibilities suggest that it doesn't have yet. */
293export async function routines(ctx: ViewContext, handle: string): Promise<Result<{ routines: AgentRoutine[]; suggestions: RoutineSuggestion[] }>> {
294 const agent = await agentByHandle(ctx, handle);
295 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
296 const rows = await ctx.db.prepare("SELECT * FROM agent_routines WHERE agent_id = ? ORDER BY created_at").bind(agent.id).all<RoutineRow>();
297 const list = rows.results.map(toRoutine);
298 const duties = definitionOf(agent).responsibilities;
299 return ok({ routines: list, suggestions: suggestRoutines(duties, list.map((r) => ({ name: r.name, events: r.events }))) });
300}
301
302/**
303 * Owners keep an agent's routines. The person who saves one becomes its
304 * sponsor: it runs with their access, in a channel they and the agent are in.
305 */
306export async function saveRoutine(ctx: ViewContext, handle: string, input: NewRoutine, id: string | null): Promise<Result<AgentRoutine>> {
307 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set up routines.");
308 if (ctx.viewer.kind === "workspace") return fail("invalid", "A routine runs with a person's access: set it up signed in as yourself.");
309 const agent = await agentByHandle(ctx, handle);
310 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
311 const checked = checkRoutine(input);
312 if (!checked.ok) return fail("invalid", checked.message);
313 const r = checked.value;
314 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, r.channel_id).catch(() => null);
315 if (!audience?.ok || (audience.value.kind !== "public" && !audience.value.member_user_ids.includes(ctx.viewer.id))) {
316 return fail("invalid", "Choose a channel you're in.");
317 }
318 const channels = await chatClient(ctx.env.CHAT).sidebar(ctx.slug, ctx.viewer).catch(() => null);
319 const channelName = channels?.ok ? (channelNameIn(channels.value, r.channel_id) ?? null) : null;
320 const now = new Date();
321 const next = r.enabled !== false && r.schedule ? nextRun(r.schedule, now).toISOString() : null;
322 const schedule = r.schedule ? JSON.stringify(r.schedule) : null;
323 if (id) {
324 const existing = await ctx.db.prepare("SELECT id FROM agent_routines WHERE id = ? AND agent_id = ?").bind(id, agent.id).first();
325 if (!existing) return fail("not_found", "There is no such routine.");
326 await ctx.db
327 .prepare(
328 `UPDATE agent_routines SET name = ?, instructions = ?, schedule = ?, events = ?, repos = ?, channel_id = ?, channel_name = ?, sponsor = ?, sponsor_username = ?,
329 enabled = ?, paused_note = NULL, next_run_at = ?, workspace = ?, updated_at = ? WHERE id = ?`,
330 )
331 .bind(r.name, r.instructions, schedule, JSON.stringify(r.events), JSON.stringify(r.repos), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, ctx.slug, now.toISOString(), id)
332 .run();
333 } else {
334 const count = await ctx.db.prepare("SELECT COUNT(*) AS n FROM agent_routines WHERE agent_id = ?").bind(agent.id).first<{ n: number }>();
335 if ((count?.n ?? 0) >= MAX_ROUTINES) return fail("invalid", `An agent keeps at most ${MAX_ROUTINES} routines.`);
336 id = newRoutineId();
337 await ctx.db
338 .prepare(
339 `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, schedule, events, repos, channel_id, channel_name, sponsor, sponsor_username, enabled, next_run_at, created_at, updated_at)
340 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
341 )
342 .bind(id, agent.id, ctx.workspaceId, ctx.slug, r.name, r.instructions, schedule, JSON.stringify(r.events), JSON.stringify(r.repos), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, now.toISOString(), now.toISOString())
343 .run();
344 }
345 const row = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ?").bind(id).first<RoutineRow>();
346 return ok(toRoutine(row!));
347}
348
349function channelNameIn(sidebar: unknown, id: string): string | null {
350 const seen: unknown[] = [sidebar];
351 while (seen.length) {
352 const value = seen.pop();
353 if (Array.isArray(value)) seen.push(...value);
354 else if (value && typeof value === "object") {
355 const v = value as Record<string, unknown>;
356 if (v.id === id && typeof v.name === "string") return v.name;
357 seen.push(...Object.values(v));
358 }
359 }
360 return null;
361}
362
363export async function deleteRoutine(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
364 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners change routines.");
365 const agent = await agentByHandle(ctx, handle);
366 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
367 await ctx.db.prepare("DELETE FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).run();
368 return ok(null);
369}
370
371export async function runRoutineNow(ctx: ViewContext, handle: string, id: string): Promise<Result<AgentSession>> {
372 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners run routines by hand.");
373 const agent = await agentByHandle(ctx, handle);
374 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
375 const routine = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<RoutineRow>();
376 if (!routine) return fail("not_found", "There is no such routine.");
377 const ran = await runRoutine(ctx.env, routine, agent, ctx.slug);
378 if (!ran.ok) return fail("invalid", ran.message);
379 const row = await sessionRow(ctx.db, ran.session);
380 return ok((await sessionsOut(ctx, [row!]))[0]);
381}
382
383// ── Spend ─────────────────────────────────────────────────────────────────
384
385const KIND_LABELS: Record<string, string> = { reply: "Chat replies", chat: "Sessions", routine: "Routines", helper: "Helping colleagues", subagent: "Subagents" };
386
387function slices(rows: { key: string | null; micros: number; n: number }[], label: (key: string) => string): SpendSlice[] {
388 return rows
389 .filter((r) => r.micros > 0 || r.n > 0)
390 .map((r) => ({ key: r.key ?? "", label: label(r.key ?? ""), micros: r.micros, count: r.n }))
391 .sort((a, b) => b.micros - a.micros);
392}
393
394/** The most channels named in a breakdown; the rest of the channels fold into one slice. */
395const CHANNEL_SLICES = 12;
396
397/**
398 * Spend by where it was asked: the costliest channels by name, if the
399 * viewer can read them; direct messages together; channels they can't
400 * read, and channels past the first few, folded together without names.
401 */
402async function channelSlices(ctx: ViewContext, rows: { key: string | null; label: string | null; micros: number; n: number }[]): Promise<SpendSlice[]> {
403 const ranked = rows.filter((r) => r.micros > 0 || r.n > 0).sort((a, b) => b.micros - a.micros);
404 const named = ranked.filter((r) => r.key && r.key !== "dm").slice(0, CHANNEL_SLICES);
405 const can = await readable(ctx, named.map((r) => r.key!));
406 const out: SpendSlice[] = [];
407 const hidden: SpendSlice = { key: "private", label: "Channels you're not in", micros: 0, count: 0 };
408 const rest: SpendSlice = { key: "other", label: "Other channels", micros: 0, count: 0 };
409 for (const row of ranked) {
410 if (row.key === "dm") out.push({ key: "dm", label: "Direct messages", micros: row.micros, count: row.n });
411 else if (!named.includes(row)) (rest.micros += row.micros), (rest.count += row.n);
412 else if (can.has(row.key!)) out.push({ key: row.key!, label: row.label ? `#${row.label}` : "A channel", micros: row.micros, count: row.n });
413 else (hidden.micros += row.micros), (hidden.count += row.n);
414 }
415 for (const folded of [hidden, rest]) if (folded.count > 0) out.push(folded);
416 return out.sort((a, b) => b.micros - a.micros);
417}
418
419/**
420 * Budgets per person this month (person-budget.ts): owners see everyone's,
421 * anyone else only their own.
422 */
423export async function personBudgetsView(ctx: ViewContext): Promise<Result<PersonBudgets>> {
424 const now = new Date();
425 const policyRow = await readPolicy(ctx.db, ctx.workspaceId, monthKey(now));
426 const only = ctx.owner ? null : ctx.viewer.kind === "workspace" ? null : ctx.viewer.username.toLowerCase();
427 return ok(await personBudgets(ctx.db, ctx.workspaceId, policyRow.person_monthly_micros, now, only));
428}
429
430/** Gives a person a budget of their own (0: none at all), or with null puts them back on the default. Owners only. */
431export async function setPersonBudget(ctx: ViewContext, username: unknown, monthly: unknown): Promise<Result<PersonBudgets>> {
432 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set budgets for people.");
433 const name = cleanUsername(username);
434 if (!name) return fail("invalid", "That isn't a username.");
435 if (monthly === null || monthly === undefined) {
436 await ctx.db.prepare("DELETE FROM person_budgets WHERE workspace_id = ? AND username = ?").bind(ctx.workspaceId, name).run();
437 } else {
438 const value = Number(monthly);
439 if (!Number.isFinite(value) || value < 0 || value > 1_000_000_000_000) return fail("invalid", "A budget is a positive amount, or none.");
440 await ctx.db
441 .prepare(
442 `INSERT INTO person_budgets (workspace_id, username, monthly_micros, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
443 ON CONFLICT (workspace_id, username) DO UPDATE SET monthly_micros = ?3, updated_by = ?4, updated_at = ?5`,
444 )
445 .bind(ctx.workspaceId, name, Math.floor(value), ctx.viewer.username, new Date().toISOString())
446 .run();
447 }
448 return personBudgetsView(ctx);
449}
450
451/**
452 * Where the spend went over a span of days (this month unless asked), for
453 * one agent (what it was paid for: its replies and every session it paid
454 * for, colleagues' help included) or for every agent; for everyone, or only
455 * the work one person asked for.
456 */
457export async function spend(ctx: ViewContext, handle: string | null, options: { period?: unknown; person?: unknown } = {}): Promise<Result<AgentSpendBreakdown>> {
458 const now = new Date();
459 const span = spendSpan(options.period, now);
460 const from = `${span.from}T00:00:00.000Z`;
461 const end = new Date(`${span.until}T00:00:00.000Z`);
462 end.setUTCDate(end.getUTCDate() + 1);
463 const to = end.toISOString();
464 const person = options.person == null || options.person === "" ? null : cleanUsername(options.person);
465 if (options.person != null && options.person !== "" && !person) return fail("invalid", "That isn't a username.");
466 let agentId: string | null = null;
467 if (handle) {
468 const agent = await agentByHandle(ctx, handle);
469 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
470 agentId = agent.id;
471 }
472 const db = ctx.db;
473 const who = person ? " AND asked_by_username = ?4" : "";
474 const rFilter = `${agentId ? "agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
475 const sFilter = `${agentId ? "payer_agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
476 const binds: string[] = [from, agentId ?? ctx.workspaceId, to, ...(person ? [person] : [])];
477 // Where it was asked: a channel by id; direct messages (a reply's channel has no name) together.
478 const union = `SELECT 'reply' AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
479 CASE WHEN channel_name IS NULL THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_replies WHERE ${rFilter}
480 UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
481 CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL
482 UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day,
483 CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NOT NULL`;
484 // A child's spend is already counted on its root (sessions.ts), so children add counts, not money.
485 const group = (column: string) =>
486 db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(...binds).all<{ key: string | null; micros: number; n: number }>();
487 const [byKind, byModel, byPerson, byAgent, byDay, byChannel, top, agents] = await Promise.all([
488 group("kind"),
489 group("model"),
490 group("person"),
491 group("agent"),
492 group("day"),
493 db
494 .prepare(`SELECT channel AS key, MAX(channel_label) AS label, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY channel`)
495 .bind(...binds)
496 .all<{ key: string | null; label: string | null; micros: number; n: number }>(),
497 db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(...binds).all<SessionRow>(),
498 faces(ctx),
499 ]);
500 const channels = await channelSlices(ctx, byChannel.results);
501 const byTeamMap = new Map<string, SpendSlice>();
502 for (const row of byAgent.results) {
503 const face = agents.get(row.key ?? "");
504 const team = face?.team || face?.department || "No team";
505 const slice = byTeamMap.get(team) ?? { key: team, label: team, micros: 0, count: 0 };
506 slice.micros += row.micros;
507 slice.count += row.n;
508 byTeamMap.set(team, slice);
509 }
510 const total = byKind.results.reduce((n, r) => n + r.micros, 0);
511 return ok({
512 period: span.period,
513 span: span.span,
514 from: span.from,
515 until: span.until,
516 person,
517 total_micros: total,
518 by_kind: slices(byKind.results, (k) => KIND_LABELS[k] ?? k),
519 by_model: slices(byModel.results, (k) => k || "No model"),
520 by_person: slices(byPerson.results, (k) => (k ? `@${k}` : "Routines and agents")),
521 by_agent: slices(byAgent.results, (k) => {
522 const face = agents.get(k);
523 return face ? `${face.display_name} (@${face.handle})` : "An archived agent";
524 }),
525 by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros),
526 by_channel: channels,
527 top_sessions: await sessionsOut(ctx, top.results, agents),
528 days: byDay.results.map((r) => ({ day: r.key ?? "", micros: r.micros })).sort((a, b) => a.day.localeCompare(b.day)),
529 });
530}
531
532// ── Activity and versions ────────────────────────────────────────────────
533
534export async function activity(ctx: ViewContext, handle: string): Promise<Result<AgentActivity[]>> {
535 const agent = await agentByHandle(ctx, handle);
536 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
537 const [replies, sessions] = await Promise.all([
538 ctx.db
539 .prepare(
540 "SELECT id, status, channel_id, channel_name, asked_by_username, model, tool_count, charged_micros, created_at, reply_id FROM agent_replies WHERE agent_id = ? ORDER BY created_at DESC LIMIT 60",
541 )
542 .bind(agent.id)
543 .all<{ id: string; status: string; channel_id: string; channel_name: string | null; asked_by_username: string | null; model: string | null; tool_count: number | null; charged_micros: number; created_at: string; reply_id: string | null }>(),
544 ctx.db.prepare("SELECT * FROM agent_sessions WHERE agent_id = ? ORDER BY created_at DESC LIMIT 40").bind(agent.id).all<SessionRow>(),
545 ]);
546 const can = await readable(ctx, [...replies.results.map((r) => r.channel_id), ...sessions.results.map((s) => s.channel_id)]);
547 const items: AgentActivity[] = [
548 ...replies.results.map((r) => {
549 const visible = can.has(r.channel_id);
550 return {
551 id: r.id,
552 kind: "reply" as const,
553 status: r.status,
554 channel_id: r.channel_id,
555 channel_name: visible ? r.channel_name : null,
556 title: null,
557 asked_by_username: visible ? r.asked_by_username : null,
558 model: r.model,
559 tools: r.tool_count ?? 0,
560 charged_micros: r.charged_micros,
561 created_at: r.created_at,
562 visible,
563 ref: visible ? r.reply_id : null,
564 };
565 }),
566 ...sessions.results.map((s) => {
567 const visible = can.has(s.channel_id);
568 return {
569 id: s.id,
570 kind: "session" as const,
571 status: s.status,
572 channel_id: s.channel_id,
573 channel_name: visible ? s.channel_name : null,
574 title: visible ? s.title : null,
575 asked_by_username: visible ? s.asked_by_username : null,
576 model: s.model,
577 tools: s.tool_calls,
578 charged_micros: s.charged_micros,
579 created_at: s.created_at,
580 visible,
581 ref: s.id,
582 };
583 }),
584 ];
585 return ok(items.sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 80));
586}
587
588export async function versions(ctx: ViewContext, handle: string): Promise<Result<AgentVersion[]>> {
589 const agent = await agentByHandle(ctx, handle);
590 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
591 const rows = await ctx.db
592 .prepare("SELECT version, definition, changed_by, created_at FROM agent_versions WHERE agent_id = ? ORDER BY version DESC LIMIT 50")
593 .bind(agent.id)
594 .all<{ version: number; definition: string; changed_by: string; created_at: string }>();
595 return ok(
596 rows.results.map((r) => {
597 let definition = {};
598 try {
599 definition = JSON.parse(r.definition);
600 } catch {
601 // An unreadable old version shows as empty.
602 }
603 return { version: r.version, changed_by: r.changed_by, created_at: r.created_at, definition };
604 }),
605 );
606}
607
608// ── Policy and overview ──────────────────────────────────────────────────
609
610export async function policy(ctx: ViewContext): Promise<Result<AgentPolicy>> {
611 const row = await readPolicy(ctx.db, ctx.workspaceId, monthKey(new Date()));
612 return ok(policyOf(row));
613}
614
615function policyOf(row: AgentPolicy): AgentPolicy {
616 return {
617 monthly_micros: row.monthly_micros,
618 default_agent_monthly_micros: row.default_agent_monthly_micros,
619 default_session_micros: row.default_session_micros,
620 person_monthly_micros: row.person_monthly_micros,
621 };
622}
623
624export async function setPolicy(ctx: ViewContext, changes: Partial<AgentPolicy>): Promise<Result<AgentPolicy>> {
625 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set its agents' budget.");
626 const current = await policy(ctx);
627 const checked = checkPolicy(current.ok ? current.value : DEFAULT_POLICY, changes ?? {});
628 if (!checked.ok) return fail("invalid", checked.message);
629 const p = checked.value;
630 await ctx.db
631 .prepare(
632 `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros, updated_by, updated_at)
633 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
634 ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, person_monthly_micros = ?5, updated_by = ?6, updated_at = ?7`,
635 )
636 .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, p.person_monthly_micros, ctx.viewer.username, new Date().toISOString())
637 .run();
638 return ok(p);
639}
640
641export async function overview(ctx: ViewContext): Promise<Result<AgentsOverview>> {
642 const now = new Date();
643 const db = ctx.db;
644 const [rows, policyRow, live, recent, upcoming, breakdown, agentFaces] = await Promise.all([
645 db.prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL")} ORDER BY a.builtin DESC, a.handle`).bind(...periods(now), ctx.workspaceId).all<Row>(),
646 readPolicy(db, ctx.workspaceId, monthKey(now)),
647 db
648 .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND status IN (${LIVE.map(() => "?").join(", ")}) ORDER BY created_at DESC LIMIT 60`)
649 .bind(ctx.workspaceId, ...LIVE)
650 .all<SessionRow>(),
651 db
652 .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND parent_id IS NULL AND status IN ('done','failed','stopped') ORDER BY finished_at DESC LIMIT 12`)
653 .bind(ctx.workspaceId)
654 .all<SessionRow>(),
655 db.prepare("SELECT * FROM agent_routines WHERE workspace_id = ? AND enabled = 1 AND next_run_at IS NOT NULL ORDER BY next_run_at LIMIT 6").bind(ctx.workspaceId).all<RoutineRow>(),
656 spend(ctx, null),
657 faces(ctx),
658 ]);
659 const agents: WorkspaceAgent[] = rows.results.map((row) => toAgent(row, now));
660 const liveSessions = await sessionsOut(ctx, live.results, agentFaces);
661 const liveByAgent: Record<string, number> = {};
662 for (const s of live.results) liveByAgent[s.agent_id] = (liveByAgent[s.agent_id] ?? 0) + 1;
663 const level = policyRow.monthly_micros ? [100, 90, 75].find((l) => (policyRow.spent * 100) / policyRow.monthly_micros! >= l) ?? null : null;
664 return ok({
665 policy: policyOf(policyRow),
666 spent_month_micros: policyRow.spent,
667 alert: level,
668 agents,
669 live_by_agent: liveByAgent,
670 live: liveSessions.filter((s) => s.visible && !s.parent_id).slice(0, 20),
671 waiting_on_you: ctx.owner ? liveSessions.filter((s) => s.status === "needs_approval") : liveSessions.filter((s) => s.status === "needs_approval" && s.visible && s.asked_by === ctx.viewer.id),
672 recent: (await sessionsOut(ctx, recent.results, agentFaces)).filter((s) => s.visible).slice(0, 8),
673 upcoming: upcoming.results.map((r) => {
674 const face = agentFaces.get(r.agent_id);
675 return { ...toRoutine(r), agent_handle: face?.handle ?? "agent", agent_name: face?.display_name ?? "An agent" };
676 }),
677 spend: breakdown.ok ? breakdown.value : emptySpend(now),
678 can_manage: ctx.owner,
679 });
680}
681
682/** A breakdown with nothing in it, for this month. */
683function emptySpend(now: Date): AgentSpendBreakdown {
684 const span = spendSpan("month", now);
685 return { ...span, person: null, total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], by_channel: [], top_sessions: [], days: [] };
686}