Skip to content
566 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked1/**
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 AgentSpendBreakdown,
20 type AgentVersion,
21 type AgentsOverview,
22 type AgentMemoryScope,
23 type NewRoutine,
24 type Result,
25 type SessionEvent,
26 type SpendSlice,
27 type User,
28 type WorkspaceAgent,
29 chatClient,
30 fail,
31 newId,
32 ok,
33} from "@g1t/contracts";
34
35import { monthKey } from "./budget.ts";
36import { type MemoryRow, type MemoryViewer, changeableBy, cleanFact, toMemory, visibleTo } from "./memory.ts";
37import { DEFAULT_POLICY, checkPolicy, readPolicy } from "./policy.ts";
38import { type RoutineRow, MAX_ROUTINES, checkRoutine, newRoutineId, nextRun, runRoutine, toRoutine } from "./routines.ts";
39import { type SessionEnv, type SessionRow, LIVE, approve, sessionRow, steer, stop, toSession } from "./sessions.ts";
40import { type Row, periods, selectAgents, toAgent } from "./store.ts";
41
42export type ViewContext = {
43 env: SessionEnv;
44 db: D1Database;
45 slug: string;
46 workspaceId: string;
47 viewer: User;
48 /** Whether the viewer owns the workspace (or is its token). */
49 owner: boolean;
50};
51
52type AgentFace = { handle: string; display_name: string; avatar_seed: string; team: string | null; department: string };
53
54/** The workspace's agents by id, archived ones too, for names on sessions and spend. */
55async function faces(ctx: ViewContext): Promise<Map<string, AgentFace>> {
56 const rows = await ctx.db
57 .prepare("SELECT id, handle, display_name, avatar_seed, team, department FROM agents WHERE workspace_id = ?")
58 .bind(ctx.workspaceId)
59 .all<{ id: string } & AgentFace>();
60 return new Map(rows.results.map((r) => [r.id, { ...r, avatar_seed: r.avatar_seed || r.handle }]));
61}
62
63/**
64 * Which of these conversations the viewer is in (or can read: a public
65 * channel). Asked of chat once per conversation, at most 60.
66 */
67async function readable(ctx: ViewContext, channelIds: string[]): Promise<Set<string>> {
68 const out = new Set<string>();
69 if (ctx.viewer.kind === "workspace") return out;
70 const chat = chatClient(ctx.env.CHAT);
71 const ids = [...new Set(channelIds)].slice(0, 60);
72 await Promise.all(
73 ids.map(async (id) => {
74 const audience = await chat.audience(ctx.slug, id).catch(() => null);
75 if (audience?.ok && (audience.value.kind === "public" || audience.value.member_user_ids.includes(ctx.viewer.id))) out.add(id);
76 }),
77 );
78 return out;
79}
80
81async function sessionsOut(ctx: ViewContext, rows: SessionRow[], agents?: Map<string, AgentFace>): Promise<AgentSession[]> {
82 const names = agents ?? (await faces(ctx));
83 const can = await readable(ctx, rows.map((r) => r.channel_id));
84 return rows.map((row) => toSession(row, names.get(row.agent_id) ?? null, can.has(row.channel_id)));
85}
86
87async function agentByHandle(ctx: ViewContext, handle: string): Promise<Row | null> {
88 return ctx.db
89 .prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
90 .bind(ctx.workspaceId, String(handle ?? "").trim().replace(/^@/, "").toLowerCase())
91 .first<Row>();
92}
93
94// ── Sessions ──────────────────────────────────────────────────────────────
95
96export async function listSessions(ctx: ViewContext, filter: { handle?: string | null; status?: "live" | "done" | null; limit?: number | null }): Promise<Result<AgentSession[]>> {
97 const limit = Math.min(200, Math.max(1, Math.floor(Number(filter.limit) || 50)));
98 const where = ["workspace_id = ?"];
99 const binds: (string | number)[] = [ctx.workspaceId];
100 if (filter.handle) {
101 const agent = await agentByHandle(ctx, filter.handle);
102 if (!agent) return fail("not_found", `There is no agent called @${filter.handle}.`);
103 where.push("agent_id = ?");
104 binds.push(agent.id);
105 }
106 if (filter.status === "live") where.push(`status IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
107 if (filter.status === "done") where.push(`status NOT IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
108 const rows = await ctx.db
109 .prepare(`SELECT * FROM agent_sessions WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`)
110 .bind(...binds, limit)
111 .all<SessionRow>();
112 return ok(await sessionsOut(ctx, rows.results));
113}
114
115export async function sessionDetail(ctx: ViewContext, id: string): Promise<Result<AgentSessionDetail>> {
116 const row = await sessionRow(ctx.db, String(id ?? ""));
117 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
118 const agents = await faces(ctx);
119 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>();
120 const [session] = await sessionsOut(ctx, [row], agents);
121 const tree = await sessionsOut(ctx, treeRows.results, agents);
122 let events: SessionEvent[] = [];
123 if (session.visible) {
124 const rows = await ctx.db
125 .prepare("SELECT seq, kind, by_name, body, tool, outcome, created_at FROM agent_session_events WHERE session_id = ? ORDER BY seq LIMIT 1000")
126 .bind(row.id)
127 .all<{ seq: number; kind: SessionEvent["kind"]; by_name: string | null; body: string; tool: string | null; outcome: string | null; created_at: string }>();
128 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 }));
129 }
130 const live = LIVE.includes(row.status as (typeof LIVE)[number]);
131 return ok({
132 session,
133 events,
134 tree,
135 can_stop: session.visible && live,
136 can_steer: session.visible,
137 can_approve: row.status === "needs_approval" && (ctx.owner || (session.visible && row.asked_by === ctx.viewer.id && ctx.owner)),
138 });
139}
140
141async function visibleSession(ctx: ViewContext, id: string): Promise<Result<SessionRow>> {
142 const row = await sessionRow(ctx.db, String(id ?? ""));
143 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
144 const can = await readable(ctx, [row.channel_id]);
145 if (!can.has(row.channel_id)) return fail("not_found", "There is no such session.");
146 return ok(row);
147}
148
149export async function stopSession(ctx: ViewContext, id: string): Promise<Result<AgentSession>> {
150 const found = await visibleSession(ctx, id);
151 if (!found.ok) return found;
152 if (!LIVE.includes(found.value.status as (typeof LIVE)[number])) return fail("invalid", "That session is already over.");
153 const row = await stop(ctx.env, found.value, ctx.viewer.username);
154 return ok((await sessionsOut(ctx, [row]))[0]);
155}
156
157export async function steerSession(ctx: ViewContext, id: string, body: string): Promise<Result<AgentSession>> {
158 const found = await visibleSession(ctx, id);
159 if (!found.ok) return found;
160 const text = typeof body === "string" ? body.trim() : "";
161 if (!text) return fail("invalid", "Say something to the session.");
162 const row = await steer(ctx.env, found.value, ctx.viewer.username, text.slice(0, 4000));
163 return ok((await sessionsOut(ctx, [row]))[0]);
164}
165
166/** Owners raise a session's cap; it must end up above what it has spent. */
167export async function approveSession(ctx: ViewContext, id: string, capMicros: number): Promise<Result<AgentSession>> {
168 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners can approve more spend.");
169 const row = await sessionRow(ctx.db, String(id ?? ""));
170 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
171 if (row.status !== "needs_approval") return fail("invalid", "That session isn't waiting for approval.");
172 const cap = Math.floor(Number(capMicros));
173 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.");
174 const fresh = await approve(ctx.env, row, ctx.viewer.username, cap);
175 return ok((await sessionsOut(ctx, [fresh]))[0]);
176}
177
178// ── Memory ────────────────────────────────────────────────────────────────
179
180async function memoryViewer(ctx: ViewContext, rows: MemoryRow[]): Promise<MemoryViewer> {
181 const channels = await readable(ctx, rows.filter((r) => r.scope === "channel").map((r) => r.scope_ref));
182 return { id: ctx.viewer.id, owner: ctx.owner, inChannel: (id) => channels.has(id) };
183}
184
185export async function memories(ctx: ViewContext, handle: string): Promise<Result<AgentMemory[]>> {
186 const agent = await agentByHandle(ctx, handle);
187 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
188 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>();
189 const viewer = await memoryViewer(ctx, rows.results);
190 return ok(rows.results.filter((row) => visibleTo(row, viewer)).map(toMemory));
191}
192
193/**
194 * A fact a person gives an agent. Workspace facts are the owners'; a
195 * channel fact needs the person to be in that channel; a person fact is
196 * always their own.
197 */
198export async function remember(ctx: ViewContext, handle: string, input: { body: string; scope: AgentMemoryScope; scope_ref?: string | null }): Promise<Result<AgentMemory>> {
199 const agent = await agentByHandle(ctx, handle);
200 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
201 const body = cleanFact(input?.body);
202 if (!body) return fail("invalid", "Say what it should remember.");
203 let scope: AgentMemoryScope = input?.scope === "workspace" || input?.scope === "channel" ? input.scope : "person";
204 let ref = "";
205 let label: string | null = null;
206 if (scope === "workspace" && !ctx.owner) return fail("forbidden", "Only owners give an agent facts for the whole workspace.");
207 if (scope === "channel") {
208 ref = String(input.scope_ref ?? "");
209 const can = await readable(ctx, [ref]);
210 if (!can.has(ref)) return fail("forbidden", "You can only give it facts for conversations you're in.");
211 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, ref).catch(() => null);
212 label = audience?.ok && "name" in audience.value ? ((audience.value as { name?: string | null }).name ?? null) : null;
213 }
214 if (scope === "person") {
215 scope = "person";
216 ref = ctx.viewer.id;
217 label = ctx.viewer.username;
218 }
219 const id = newId("mem");
220 const now = new Date().toISOString();
221 await ctx.db
222 .prepare(
223 `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)
224 VALUES (?, ?, ?, ?, ?, ?, ?, 'person', ?, ?, ?, 'user', 1, ?, ?)`,
225 )
226 .bind(id, agent.id, ctx.workspaceId, scope, ref, label, body, ctx.viewer.username, `@${ctx.viewer.username}`, ctx.viewer.username, now, now)
227 .run();
228 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(id).first<MemoryRow>();
229 return ok(toMemory(row!));
230}
231
232async function changeable(ctx: ViewContext, handle: string, id: string): Promise<Result<MemoryRow>> {
233 const agent = await agentByHandle(ctx, handle);
234 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
235 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<MemoryRow>();
236 if (!row) return fail("not_found", "There is no such memory.");
237 const viewer = await memoryViewer(ctx, [row]);
238 if (!visibleTo(row, viewer)) return fail("not_found", "There is no such memory.");
239 if (!changeableBy(row, viewer)) return fail("forbidden", "Only owners change what an agent knows for the whole workspace.");
240 return ok(row);
241}
242
243export async function updateMemory(ctx: ViewContext, handle: string, id: string, changes: { body?: string; pinned?: boolean }): Promise<Result<AgentMemory>> {
244 const found = await changeable(ctx, handle, id);
245 if (!found.ok) return found;
246 const body = changes?.body === undefined ? found.value.body : cleanFact(changes.body);
247 if (!body) return fail("invalid", "A memory can't be empty; forget it instead.");
248 const pinned = changes?.pinned === undefined ? found.value.pinned : changes.pinned ? 1 : 0;
249 const now = new Date().toISOString();
250 const edited = body !== found.value.body;
251 await ctx.db
252 .prepare(
253 `UPDATE agent_memories SET body = ?, pinned = ?, updated_at = ?${edited ? ", source_kind = 'person', source_ref = ?, source_label = ?" : ""} WHERE id = ?`,
254 )
255 .bind(...(edited ? [body, pinned, now, ctx.viewer.username, `@${ctx.viewer.username} (corrected)`, found.value.id] : [body, pinned, now, found.value.id]))
256 .run();
257 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(found.value.id).first<MemoryRow>();
258 return ok(toMemory(row!));
259}
260
261export async function forget(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
262 const found = await changeable(ctx, handle, id);
263 if (!found.ok) return found;
264 await ctx.db.prepare("DELETE FROM agent_memories WHERE id = ?").bind(found.value.id).run();
265 return ok(null);
266}
267
268// ── Routines ──────────────────────────────────────────────────────────────
269
270export async function routines(ctx: ViewContext, handle: string): Promise<Result<AgentRoutine[]>> {
271 const agent = await agentByHandle(ctx, handle);
272 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
273 const rows = await ctx.db.prepare("SELECT * FROM agent_routines WHERE agent_id = ? ORDER BY created_at").bind(agent.id).all<RoutineRow>();
274 return ok(rows.results.map(toRoutine));
275}
276
277/**
278 * Owners keep an agent's routines. The person who saves one becomes its
279 * sponsor: it runs with their access, in a channel they and the agent are in.
280 */
281export async function saveRoutine(ctx: ViewContext, handle: string, input: NewRoutine, id: string | null): Promise<Result<AgentRoutine>> {
282 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set up routines.");
283 if (ctx.viewer.kind === "workspace") return fail("invalid", "A routine runs with a person's access: set it up signed in as yourself.");
284 const agent = await agentByHandle(ctx, handle);
285 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
286 const checked = checkRoutine(input);
287 if (!checked.ok) return fail("invalid", checked.message);
288 const r = checked.value;
289 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, r.channel_id).catch(() => null);
290 if (!audience?.ok || (audience.value.kind !== "public" && !audience.value.member_user_ids.includes(ctx.viewer.id))) {
291 return fail("invalid", "Choose a channel you're in.");
292 }
293 const channels = await chatClient(ctx.env.CHAT).sidebar(ctx.slug, ctx.viewer).catch(() => null);
294 const channelName = channels?.ok ? (channelNameIn(channels.value, r.channel_id) ?? null) : null;
295 const now = new Date();
296 const next = r.enabled !== false ? nextRun(r.schedule, now).toISOString() : null;
297 if (id) {
298 const existing = await ctx.db.prepare("SELECT id FROM agent_routines WHERE id = ? AND agent_id = ?").bind(id, agent.id).first();
299 if (!existing) return fail("not_found", "There is no such routine.");
300 await ctx.db
301 .prepare(
302 `UPDATE agent_routines SET name = ?, instructions = ?, schedule = ?, channel_id = ?, channel_name = ?, sponsor = ?, sponsor_username = ?,
303 enabled = ?, paused_note = NULL, next_run_at = ?, workspace = ?, updated_at = ? WHERE id = ?`,
304 )
305 .bind(r.name, r.instructions, JSON.stringify(r.schedule), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, ctx.slug, now.toISOString(), id)
306 .run();
307 } else {
308 const count = await ctx.db.prepare("SELECT COUNT(*) AS n FROM agent_routines WHERE agent_id = ?").bind(agent.id).first<{ n: number }>();
309 if ((count?.n ?? 0) >= MAX_ROUTINES) return fail("invalid", `An agent keeps at most ${MAX_ROUTINES} routines.`);
310 id = newRoutineId();
311 await ctx.db
312 .prepare(
313 `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, schedule, channel_id, channel_name, sponsor, sponsor_username, enabled, next_run_at, created_at, updated_at)
314 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
315 )
316 .bind(id, agent.id, ctx.workspaceId, ctx.slug, r.name, r.instructions, JSON.stringify(r.schedule), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, now.toISOString(), now.toISOString())
317 .run();
318 }
319 const row = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ?").bind(id).first<RoutineRow>();
320 return ok(toRoutine(row!));
321}
322
323function channelNameIn(sidebar: unknown, id: string): string | null {
324 const seen: unknown[] = [sidebar];
325 while (seen.length) {
326 const value = seen.pop();
327 if (Array.isArray(value)) seen.push(...value);
328 else if (value && typeof value === "object") {
329 const v = value as Record<string, unknown>;
330 if (v.id === id && typeof v.name === "string") return v.name;
331 seen.push(...Object.values(v));
332 }
333 }
334 return null;
335}
336
337export async function deleteRoutine(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
338 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners change routines.");
339 const agent = await agentByHandle(ctx, handle);
340 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
341 await ctx.db.prepare("DELETE FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).run();
342 return ok(null);
343}
344
345export async function runRoutineNow(ctx: ViewContext, handle: string, id: string): Promise<Result<AgentSession>> {
346 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners run routines by hand.");
347 const agent = await agentByHandle(ctx, handle);
348 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
349 const routine = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<RoutineRow>();
350 if (!routine) return fail("not_found", "There is no such routine.");
351 const ran = await runRoutine(ctx.env, routine, agent, ctx.slug);
352 if (!ran.ok) return fail("invalid", ran.message);
353 const row = await sessionRow(ctx.db, ran.session);
354 return ok((await sessionsOut(ctx, [row!]))[0]);
355}
356
357// ── Spend ─────────────────────────────────────────────────────────────────
358
359const KIND_LABELS: Record<string, string> = { reply: "Chat replies", chat: "Sessions", routine: "Routines", helper: "Helping colleagues", subagent: "Subagents" };
360
361function slices(rows: { key: string | null; micros: number; n: number }[], label: (key: string) => string): SpendSlice[] {
362 return rows
363 .filter((r) => r.micros > 0 || r.n > 0)
364 .map((r) => ({ key: r.key ?? "", label: label(r.key ?? ""), micros: r.micros, count: r.n }))
365 .sort((a, b) => b.micros - a.micros);
366}
367
368/**
369 * Where the month went, for one agent (what it was paid for: its replies and
370 * every session it paid for, colleagues' help included) or for every agent.
371 */
372export async function spend(ctx: ViewContext, handle: string | null): Promise<Result<AgentSpendBreakdown>> {
373 const now = new Date();
374 const month = monthKey(now);
375 const from = `${month}-01T00:00:00.000Z`;
376 let agentId: string | null = null;
377 if (handle) {
378 const agent = await agentByHandle(ctx, handle);
379 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
380 agentId = agent.id;
381 }
382 const db = ctx.db;
383 const rFilter = agentId ? "agent_id = ?2" : "workspace_id = ?2";
384 const sFilter = agentId ? "payer_agent_id = ?2" : "workspace_id = ?2";
385 const scope = agentId ?? ctx.workspaceId;
386 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 FROM agent_replies WHERE ${rFilter} AND created_at >= ?1
387 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 FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL
388 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 FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NOT NULL`;
389 // A child's spend is already counted on its root (sessions.ts), so children add counts, not money.
390 const group = (column: string) =>
391 db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(from, scope).all<{ key: string | null; micros: number; n: number }>();
392 const [byKind, byModel, byPerson, byAgent, byDay, top, agents] = await Promise.all([
393 group("kind"),
394 group("model"),
395 group("person"),
396 group("agent"),
397 group("day"),
398 db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND created_at >= ?1 AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(from, scope).all<SessionRow>(),
399 faces(ctx),
400 ]);
401 const byTeamMap = new Map<string, SpendSlice>();
402 for (const row of byAgent.results) {
403 const face = agents.get(row.key ?? "");
404 const team = face?.team || face?.department || "No team";
405 const slice = byTeamMap.get(team) ?? { key: team, label: team, micros: 0, count: 0 };
406 slice.micros += row.micros;
407 slice.count += row.n;
408 byTeamMap.set(team, slice);
409 }
410 const total = byKind.results.reduce((n, r) => n + r.micros, 0);
411 return ok({
412 period: month,
413 total_micros: total,
414 by_kind: slices(byKind.results, (k) => KIND_LABELS[k] ?? k),
415 by_model: slices(byModel.results, (k) => k || "No model"),
416 by_person: slices(byPerson.results, (k) => (k ? `@${k}` : "Routines and agents")),
417 by_agent: slices(byAgent.results, (k) => {
418 const face = agents.get(k);
419 return face ? `${face.display_name} (@${face.handle})` : "An archived agent";
420 }),
421 by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros),
422 top_sessions: await sessionsOut(ctx, top.results, agents),
423 days: byDay.results.map((r) => ({ day: r.key ?? "", micros: r.micros })).sort((a, b) => a.day.localeCompare(b.day)),
424 });
425}
426
427// ── Activity and versions ────────────────────────────────────────────────
428
429export async function activity(ctx: ViewContext, handle: string): Promise<Result<AgentActivity[]>> {
430 const agent = await agentByHandle(ctx, handle);
431 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
432 const [replies, sessions] = await Promise.all([
433 ctx.db
434 .prepare(
435 "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",
436 )
437 .bind(agent.id)
438 .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 }>(),
439 ctx.db.prepare("SELECT * FROM agent_sessions WHERE agent_id = ? ORDER BY created_at DESC LIMIT 40").bind(agent.id).all<SessionRow>(),
440 ]);
441 const can = await readable(ctx, [...replies.results.map((r) => r.channel_id), ...sessions.results.map((s) => s.channel_id)]);
442 const items: AgentActivity[] = [
443 ...replies.results.map((r) => {
444 const visible = can.has(r.channel_id);
445 return {
446 id: r.id,
447 kind: "reply" as const,
448 status: r.status,
449 channel_id: r.channel_id,
450 channel_name: visible ? r.channel_name : null,
451 title: null,
452 asked_by_username: visible ? r.asked_by_username : null,
453 model: r.model,
454 tools: r.tool_count ?? 0,
455 charged_micros: r.charged_micros,
456 created_at: r.created_at,
457 visible,
458 ref: visible ? r.reply_id : null,
459 };
460 }),
461 ...sessions.results.map((s) => {
462 const visible = can.has(s.channel_id);
463 return {
464 id: s.id,
465 kind: "session" as const,
466 status: s.status,
467 channel_id: s.channel_id,
468 channel_name: visible ? s.channel_name : null,
469 title: visible ? s.title : null,
470 asked_by_username: visible ? s.asked_by_username : null,
471 model: s.model,
472 tools: s.tool_calls,
473 charged_micros: s.charged_micros,
474 created_at: s.created_at,
475 visible,
476 ref: s.id,
477 };
478 }),
479 ];
480 return ok(items.sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 80));
481}
482
483export async function versions(ctx: ViewContext, handle: string): Promise<Result<AgentVersion[]>> {
484 const agent = await agentByHandle(ctx, handle);
485 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
486 const rows = await ctx.db
487 .prepare("SELECT version, definition, changed_by, created_at FROM agent_versions WHERE agent_id = ? ORDER BY version DESC LIMIT 50")
488 .bind(agent.id)
489 .all<{ version: number; definition: string; changed_by: string; created_at: string }>();
490 return ok(
491 rows.results.map((r) => {
492 let definition = {};
493 try {
494 definition = JSON.parse(r.definition);
495 } catch {
496 // An unreadable old version shows as empty.
497 }
498 return { version: r.version, changed_by: r.changed_by, created_at: r.created_at, definition };
499 }),
500 );
501}
502
503// ── Policy and overview ──────────────────────────────────────────────────
504
505export async function policy(ctx: ViewContext): Promise<Result<AgentPolicy>> {
506 const row = await readPolicy(ctx.db, ctx.workspaceId, monthKey(new Date()));
507 return ok({ monthly_micros: row.monthly_micros, default_agent_monthly_micros: row.default_agent_monthly_micros, default_session_micros: row.default_session_micros });
508}
509
510export async function setPolicy(ctx: ViewContext, changes: Partial<AgentPolicy>): Promise<Result<AgentPolicy>> {
511 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set its agents' budget.");
512 const current = await policy(ctx);
513 const checked = checkPolicy(current.ok ? current.value : DEFAULT_POLICY, changes ?? {});
514 if (!checked.ok) return fail("invalid", checked.message);
515 const p = checked.value;
516 await ctx.db
517 .prepare(
518 `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, updated_by, updated_at)
519 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
520 ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, updated_by = ?5, updated_at = ?6`,
521 )
522 .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, ctx.viewer.username, new Date().toISOString())
523 .run();
524 return ok(p);
525}
526
527export async function overview(ctx: ViewContext): Promise<Result<AgentsOverview>> {
528 const now = new Date();
529 const db = ctx.db;
530 const [rows, policyRow, live, recent, upcoming, breakdown, agentFaces] = await Promise.all([
531 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>(),
532 readPolicy(db, ctx.workspaceId, monthKey(now)),
533 db
534 .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND status IN (${LIVE.map(() => "?").join(", ")}) ORDER BY created_at DESC LIMIT 60`)
535 .bind(ctx.workspaceId, ...LIVE)
536 .all<SessionRow>(),
537 db
538 .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`)
539 .bind(ctx.workspaceId)
540 .all<SessionRow>(),
541 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>(),
542 spend(ctx, null),
543 faces(ctx),
544 ]);
545 const agents: WorkspaceAgent[] = rows.results.map((row) => toAgent(row, now));
546 const liveSessions = await sessionsOut(ctx, live.results, agentFaces);
547 const liveByAgent: Record<string, number> = {};
548 for (const s of live.results) liveByAgent[s.agent_id] = (liveByAgent[s.agent_id] ?? 0) + 1;
549 const level = policyRow.monthly_micros ? [100, 90, 75].find((l) => (policyRow.spent * 100) / policyRow.monthly_micros! >= l) ?? null : null;
550 return ok({
551 policy: { monthly_micros: policyRow.monthly_micros, default_agent_monthly_micros: policyRow.default_agent_monthly_micros, default_session_micros: policyRow.default_session_micros },
552 spent_month_micros: policyRow.spent,
553 alert: level,
554 agents,
555 live_by_agent: liveByAgent,
556 live: liveSessions.filter((s) => s.visible && !s.parent_id).slice(0, 20),
557 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),
558 recent: (await sessionsOut(ctx, recent.results, agentFaces)).filter((s) => s.visible).slice(0, 8),
559 upcoming: upcoming.results.map((r) => {
560 const face = agentFaces.get(r.agent_id);
561 return { ...toRoutine(r), agent_handle: face?.handle ?? "agent", agent_name: face?.display_name ?? "An agent" };
562 }),
563 spend: breakdown.ok ? breakdown.value : { period: monthKey(now), total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], top_sessions: [], days: [] },
564 can_manage: ctx.owner,
565 });
566}