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.
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 1 | /** |
| 2 | * Spend less, keep quality (docs.g1t.sh/guides/spend/#spend-less-keep-quality): | |
| 3 | * a weekly check, per workspace, of whether each agent could run at a | |
| 4 | * cheaper effort level without its work getting worse, judged only on the | |
| 5 | * agent's own finished sessions. | |
| 6 | * | |
| 7 | * - **What counts as good work.** A session is accepted when it finished | |
| 8 | * (`done`) with nobody having to step in: no one steered it, and it was | |
| 9 | * not stopped or failed. Only root sessions count (a helper's or | |
| 10 | * subagent's work is part of its root's), and only those that recorded | |
| 11 | * the level they ran at. | |
| 12 | * - **What is compared.** The level the agent runs at now (its setting, or | |
| 13 | * for Auto the level most of its sessions ran at) against the level | |
| 14 | * below, over the last `WINDOW_DAYS`. | |
| 15 | * - **When it recommends.** Both levels have at least `MIN_SESSIONS` | |
| 16 | * sessions; the cheaper level's acceptance is no more than | |
| 17 | * `ACCEPT_TOLERANCE` below the current one's, and even its pessimistic | |
| 18 | * estimate (a Wilson lower bound) is within `ACCEPT_FLOOR`; and a typical | |
| 19 | * (median) session at the cheaper level costs at most `COST_SHARE` of | |
| 20 | * one at the current level. | |
| 21 | * - **When it can't tell.** Too few sessions on either side: a `thin` | |
| 22 | * note that says how many it has and how many it needs, never a | |
| 23 | * suggestion. When the cheaper level is measured and does worse, or | |
| 24 | * saves too little, nothing is said. | |
| 25 | * | |
| 26 | * Every figure shown comes from those sessions' recorded charges, as | |
| 27 | * budgets count them: the model and the agent rate where it applies. Pure | |
| 28 | * functions first (tested in recommend.test.ts), then the database. | |
| 29 | */ | |
| 30 | import type { AgentEffort, AgentEffortCosts, AgentRecommendation, AgentRecommendations, EffortCost, EffortEvidence, EffortLevel } from "@g1t/contracts"; | |
| 31 | ||
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 32 | import { readLook } from "../../../packages/contracts/src/agent-look.ts"; |
| 33 | ||
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 34 | import { dollars } from "./money.ts"; |
| 35 | import { effortOf, isLevel, lowerEffort } from "./routing.ts"; | |
| 36 | import { type Row, definitionOf } from "./store.ts"; | |
| 37 | ||
| 38 | export const WINDOW_DAYS = 28; | |
| 39 | export const MIN_SESSIONS = 10; | |
| 40 | export const ACCEPT_TOLERANCE = 0.05; | |
| 41 | export const ACCEPT_FLOOR = 0.15; | |
| 42 | export const COST_SHARE = 0.85; | |
| 43 | /** How often a workspace is checked. */ | |
| 44 | export const CHECK_EVERY_DAYS = 7; | |
| 45 | /** Workspaces checked per run, so one run stays short. */ | |
| 46 | const PER_RUN = 25; | |
| 47 | ||
| 48 | const LEVELS: EffortLevel[] = ["low", "medium", "high", "max"]; | |
| 49 | const DAY_MS = 86_400_000; | |
| 50 | ||
| 51 | /** One finished session, as the check reads it. */ | |
| 52 | export type SessionOutcome = { effort: EffortLevel; charged_micros: number; accepted: boolean }; | |
| 53 | ||
| 54 | export function median(values: number[]): number { | |
| 55 | if (!values.length) return 0; | |
| 56 | const sorted = [...values].sort((a, b) => a - b); | |
| 57 | const mid = Math.floor(sorted.length / 2); | |
| 58 | return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); | |
| 59 | } | |
| 60 | ||
| 61 | /** The lower end of a Wilson score interval at about 90%: how low a share could plausibly be. */ | |
| 62 | export function wilsonLower(successes: number, trials: number, z = 1.2816): number { | |
| 63 | if (trials <= 0) return 0; | |
| 64 | const p = successes / trials; | |
| 65 | const z2 = z * z; | |
| 66 | const centre = p + z2 / (2 * trials); | |
| 67 | const margin = z * Math.sqrt((p * (1 - p)) / trials + z2 / (4 * trials * trials)); | |
| 68 | return Math.max(0, (centre - margin) / (1 + z2 / trials)); | |
| 69 | } | |
| 70 | ||
| 71 | export function evidenceAt(outcomes: SessionOutcome[], effort: EffortLevel): EffortEvidence | null { | |
| 72 | const at = outcomes.filter((o) => o.effort === effort); | |
| 73 | if (!at.length) return null; | |
| 74 | const costs = at.map((o) => Math.max(0, o.charged_micros)); | |
| 75 | return { | |
| 76 | effort, | |
| 77 | sessions: at.length, | |
| 78 | accepted: at.filter((o) => o.accepted).length, | |
| 79 | typical_micros: median(costs), | |
| 80 | mean_micros: Math.round(costs.reduce((n, c) => n + c, 0) / costs.length), | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** The level an agent runs at now: its setting, or for Auto the level most of its sessions ran at (the higher on a tie). */ | |
| 85 | export function currentLevel(setting: AgentEffort, outcomes: SessionOutcome[]): EffortLevel | null { | |
| 86 | if (setting !== "auto") return setting; | |
| 87 | let best: EffortLevel | null = null; | |
| 88 | let most = 0; | |
| 89 | for (const level of LEVELS) { | |
| 90 | const n = outcomes.filter((o) => o.effort === level).length; | |
| 91 | if (n > 0 && n >= most) { | |
| 92 | best = level; | |
| 93 | most = n; | |
| 94 | } | |
| 95 | } | |
| 96 | return best; | |
| 97 | } | |
| 98 | ||
| 99 | export type Judgement = | |
| 100 | | { kind: "none" } | |
| 101 | | { kind: "thin"; from: AgentEffort; to: EffortLevel; current: EffortEvidence | null; cheaper: EffortEvidence | null } | |
| 102 | | { kind: "recommend"; from: AgentEffort; to: EffortLevel; current: EffortEvidence; cheaper: EffortEvidence; saving_month_micros: number }; | |
| 103 | ||
| 104 | /** Whether to suggest a cheaper level for one agent, from its sessions in the window. */ | |
| 105 | export function judge(setting: AgentEffort, outcomes: SessionOutcome[], windowDays = WINDOW_DAYS): Judgement { | |
| 106 | if (!outcomes.length) return { kind: "none" }; | |
| 107 | const level = currentLevel(setting, outcomes); | |
| 108 | if (!level) return { kind: "none" }; | |
| 109 | const to = lowerEffort(level); | |
| 110 | if (!to) return { kind: "none" }; | |
| 111 | const current = evidenceAt(outcomes, level); | |
| 112 | const cheaper = evidenceAt(outcomes, to); | |
| 113 | if (!current || !cheaper || current.sessions < MIN_SESSIONS || cheaper.sessions < MIN_SESSIONS) { | |
| 114 | return { kind: "thin", from: setting, to, current, cheaper }; | |
| 115 | } | |
| 116 | const rateNow = current.accepted / current.sessions; | |
| 117 | const rateCheaper = cheaper.accepted / cheaper.sessions; | |
| 118 | if (rateCheaper < rateNow - ACCEPT_TOLERANCE) return { kind: "none" }; | |
| 119 | if (wilsonLower(cheaper.accepted, cheaper.sessions) < rateNow - ACCEPT_FLOOR) return { kind: "none" }; | |
| 120 | if (current.typical_micros <= 0 || cheaper.typical_micros > current.typical_micros * COST_SHARE) return { kind: "none" }; | |
| 121 | // At the pace it ran at the current level, what the difference comes to in 30 days. | |
| 122 | const perDay = current.sessions / Math.max(1, windowDays); | |
| 123 | const saving = Math.max(0, Math.round((current.mean_micros - cheaper.mean_micros) * perDay * 30)); | |
| 124 | if (saving <= 0) return { kind: "none" }; | |
| 125 | return { kind: "recommend", from: setting, to, current, cheaper, saving_month_micros: saving }; | |
| 126 | } | |
| 127 | ||
| 128 | export const EFFORT_NAMES: Record<AgentEffort, string> = { auto: "Auto", low: "Low", medium: "Medium", high: "High", max: "Max" }; | |
| 129 | ||
| 130 | /** What a judgement says, in words built only from its numbers. */ | |
| 131 | export function wording(judgement: Exclude<Judgement, { kind: "none" }>, agentName: string, windowDays = WINDOW_DAYS): { title: string; reason: string } { | |
| 132 | const to = EFFORT_NAMES[judgement.to]; | |
| 133 | const current = judgement.current; | |
| 134 | const cheaper = judgement.cheaper; | |
| 135 | const levelNow = current ? EFFORT_NAMES[current.effort] : judgement.from === "auto" ? "its usual level" : EFFORT_NAMES[judgement.from]; | |
| 136 | if (judgement.kind === "thin") { | |
| 137 | const have = `${current?.sessions ?? 0} at ${levelNow} and ${cheaper?.sessions ?? 0} at ${to}`; | |
| 138 | return { | |
| 139 | title: `Not enough history to say whether ${agentName} can run at ${to}`, | |
| 140 | reason: `Its finished sessions in the last ${windowDays} days: ${have}. A suggestion needs at least ${MIN_SESSIONS} at each.`, | |
| 141 | }; | |
| 142 | } | |
| 143 | const c = judgement.current; | |
| 144 | const k = judgement.cheaper; | |
| 145 | return { | |
| 146 | title: `Run ${agentName} at ${to} effort`, | |
| 147 | reason: | |
| 148 | `At ${to}, ${k.accepted} of its ${k.sessions} sessions finished with nobody stepping in, against ${c.accepted} of ${c.sessions} at ${EFFORT_NAMES[c.effort]}; ` + | |
| 149 | `a typical one cost ${dollars(k.typical_micros)} instead of ${dollars(c.typical_micros)}.`, | |
| 150 | }; | |
| 151 | } | |
| 152 | ||
| 153 | // --- The database ---------------------------------------------------------- | |
| 154 | ||
| 155 | type OutcomeRow = { agent_id: string; effort: string | null; charged_micros: number; status: string; steered: number }; | |
| 156 | ||
| 157 | /** Finished root sessions with a recorded level since `since`, by agent: the check's input. */ | |
| 158 | export async function outcomesSince(db: D1Database, workspaceId: string, since: string, agentId?: string): Promise<Map<string, SessionOutcome[]>> { | |
| 159 | const rows = await db | |
| 160 | .prepare( | |
| 161 | `SELECT s.agent_id, s.effort, s.charged_micros, s.status, | |
| 162 | EXISTS (SELECT 1 FROM agent_session_events e WHERE e.session_id = s.id AND e.kind = 'steer') AS steered | |
| 163 | FROM agent_sessions s | |
| 164 | WHERE s.workspace_id = ?1 AND s.parent_id IS NULL AND s.effort IS NOT NULL AND s.finished_at >= ?2 | |
| 165 | AND s.status IN ('done', 'failed', 'stopped') AND (?3 IS NULL OR s.agent_id = ?3)`, | |
| 166 | ) | |
| 167 | .bind(workspaceId, since, agentId ?? null) | |
| 168 | .all<OutcomeRow>(); | |
| 169 | const by = new Map<string, SessionOutcome[]>(); | |
| 170 | for (const row of rows.results) { | |
| 171 | if (!isLevel(row.effort)) continue; | |
| 172 | const list = by.get(row.agent_id) ?? []; | |
| 173 | list.push({ effort: row.effort, charged_micros: row.charged_micros, accepted: row.status === "done" && !row.steered }); | |
| 174 | by.set(row.agent_id, list); | |
| 175 | } | |
| 176 | return by; | |
| 177 | } | |
| 178 | ||
| 179 | /** What each level has cost one agent, from its own sessions. */ | |
| 180 | export function effortCostsOf(handle: string, setting: AgentEffort, outcomes: SessionOutcome[], windowDays = WINDOW_DAYS): AgentEffortCosts { | |
| 181 | const levels: EffortCost[] = LEVELS.map((effort) => { | |
| 182 | const at = evidenceAt(outcomes, effort); | |
| 183 | return { | |
| 184 | effort, | |
| 185 | sessions: at?.sessions ?? 0, | |
| 186 | typical_micros: at ? at.typical_micros : null, | |
| 187 | accepted_share: at ? at.accepted / at.sessions : null, | |
| 188 | }; | |
| 189 | }); | |
| 190 | return { handle, effort: setting, window_days: windowDays, levels }; | |
| 191 | } | |
| 192 | ||
| 193 | type RecommendationRow = { | |
| 194 | id: string; | |
| 195 | workspace_id: string; | |
| 196 | agent_id: string; | |
| 197 | kind: string; | |
| 198 | status: string; | |
| 199 | from_effort: string; | |
| 200 | to_effort: string; | |
| 201 | title: string; | |
| 202 | reason: string; | |
| 203 | evidence: string; | |
| 204 | saving_month_micros: number | null; | |
| 205 | checked_at: string; | |
| 206 | resolved_by: string | null; | |
| 207 | resolved_at: string | null; | |
| 208 | handle?: string; | |
| 209 | display_name?: string; | |
| 210 | avatar_seed?: string; | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 211 | look?: string | null; |
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 212 | }; |
| 213 | ||
| 214 | function parse<T>(raw: string, fallback: T): T { | |
| 215 | try { | |
| 216 | return JSON.parse(raw) as T; | |
| 217 | } catch { | |
| 218 | return fallback; | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | export function toRecommendation(row: RecommendationRow): AgentRecommendation { | |
| 223 | const evidence = parse<AgentRecommendation["evidence"]>(row.evidence, { window_days: WINDOW_DAYS, current: null, cheaper: null, needed: MIN_SESSIONS }); | |
| 224 | return { | |
| 225 | id: row.id, | |
| 226 | agent_id: row.agent_id, | |
| 227 | agent_handle: row.handle ?? "agent", | |
| 228 | agent_name: row.display_name ?? "An agent", | |
| 229 | agent_avatar_seed: row.avatar_seed || row.handle || row.agent_id, | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 230 | agent_look: readLook(row.look ?? null), |
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 231 | kind: "effort", |
| 232 | status: (["open", "applied", "dismissed", "thin"].includes(row.status) ? row.status : "open") as AgentRecommendation["status"], | |
| 233 | from_effort: (row.from_effort as AgentEffort) ?? "auto", | |
| 234 | to_effort: isLevel(row.to_effort) ? row.to_effort : "medium", | |
| 235 | title: row.title, | |
| 236 | reason: row.reason, | |
| 237 | evidence, | |
| 238 | saving_month_micros: row.saving_month_micros, | |
| 239 | checked_at: row.checked_at, | |
| 240 | resolved_by: row.resolved_by, | |
| 241 | resolved_at: row.resolved_at, | |
| 242 | }; | |
| 243 | } | |
| 244 | ||
| 245 | const iso = (ms: number) => new Date(ms).toISOString(); | |
| 246 | ||
| 247 | /** | |
| 248 | * Checks one workspace: judges every agent that has finished sessions in | |
| 249 | * the window, and writes what it found. Open and thin suggestions are | |
| 250 | * refreshed in place; one that was applied or dismissed is left alone; | |
| 251 | * open or thin ones that no longer hold (the setting changed, or the | |
| 252 | * evidence moved) become `stale` and are no longer shown. | |
| 253 | */ | |
| 254 | export async function checkWorkspace(db: D1Database, workspaceId: string, now = Date.now()): Promise<{ open: number; thin: number }> { | |
| 255 | const since = iso(now - WINDOW_DAYS * DAY_MS); | |
| 256 | const checkedAt = iso(now); | |
| 257 | const [outcomes, agents] = await Promise.all([ | |
| 258 | outcomesSince(db, workspaceId, since), | |
| 259 | db.prepare("SELECT * FROM agents WHERE workspace_id = ? AND archived_at IS NULL").bind(workspaceId).all<Row>(), | |
| 260 | ]); | |
| 261 | const statements: D1PreparedStatement[] = []; | |
| 262 | const keep: string[] = []; | |
| 263 | let open = 0; | |
| 264 | let thin = 0; | |
| 265 | for (const agent of agents.results) { | |
| 266 | const setting = effortOf(definitionOf(agent).routing); | |
| 267 | const judgement = judge(setting, outcomes.get(agent.id) ?? []); | |
| 268 | if (judgement.kind === "none") continue; | |
| 269 | const words = wording(judgement, agent.display_name); | |
| 270 | const status = judgement.kind === "recommend" ? "open" : "thin"; | |
| 271 | if (status === "open") open++; | |
| 272 | else thin++; | |
| 273 | const evidence = JSON.stringify({ window_days: WINDOW_DAYS, current: judgement.current, cheaper: judgement.cheaper, needed: MIN_SESSIONS }); | |
| 274 | const saving = judgement.kind === "recommend" ? judgement.saving_month_micros : null; | |
| 275 | const id = `rec_${agent.id}_${judgement.from}_${judgement.to}`; | |
| 276 | keep.push(id); | |
| 277 | statements.push( | |
| 278 | db | |
| 279 | .prepare( | |
| 280 | `INSERT INTO agent_recommendations (id, workspace_id, agent_id, kind, status, from_effort, to_effort, title, reason, evidence, saving_month_micros, checked_at, created_at) | |
| 281 | VALUES (?1, ?2, ?3, 'effort', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11) | |
| 282 | ON CONFLICT (agent_id, kind, from_effort, to_effort) DO UPDATE SET | |
| 283 | status = CASE WHEN agent_recommendations.status IN ('applied', 'dismissed') THEN agent_recommendations.status ELSE excluded.status END, | |
| 284 | title = CASE WHEN agent_recommendations.status IN ('applied', 'dismissed') THEN agent_recommendations.title ELSE excluded.title END, | |
| 285 | reason = CASE WHEN agent_recommendations.status IN ('applied', 'dismissed') THEN agent_recommendations.reason ELSE excluded.reason END, | |
| 286 | evidence = CASE WHEN agent_recommendations.status IN ('applied', 'dismissed') THEN agent_recommendations.evidence ELSE excluded.evidence END, | |
| 287 | saving_month_micros = CASE WHEN agent_recommendations.status IN ('applied', 'dismissed') THEN agent_recommendations.saving_month_micros ELSE excluded.saving_month_micros END, | |
| 288 | checked_at = excluded.checked_at`, | |
| 289 | ) | |
| 290 | .bind(id, workspaceId, agent.id, status, judgement.from, judgement.to, words.title, words.reason, evidence, saving, checkedAt), | |
| 291 | ); | |
| 292 | } | |
| 293 | // What no longer holds is put away; the record stays. | |
| 294 | statements.push( | |
| 295 | db | |
| 296 | .prepare( | |
| 297 | `UPDATE agent_recommendations SET status = 'stale', checked_at = ?2 | |
| 298 | WHERE workspace_id = ?1 AND status IN ('open', 'thin') AND id NOT IN (SELECT value FROM json_each(?3))`, | |
| 299 | ) | |
| 300 | .bind(workspaceId, checkedAt, JSON.stringify(keep)), | |
| 301 | ); | |
| 302 | statements.push( | |
| 303 | db | |
| 304 | .prepare("INSERT INTO agent_recommendation_checks (workspace_id, checked_at) VALUES (?, ?) ON CONFLICT (workspace_id) DO UPDATE SET checked_at = excluded.checked_at") | |
| 305 | .bind(workspaceId, checkedAt), | |
| 306 | ); | |
| 307 | await db.batch(statements); | |
| 308 | return { open, thin }; | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * The scheduled run: the workspaces with sessions finished in the window | |
| 313 | * whose last check is a week old or more (or never), a few at a time. | |
| 314 | * Called from the cron; does its work only in the first five minutes of an | |
| 315 | * hour, so the query runs hourly, and each workspace weekly. | |
| 316 | */ | |
| 317 | export async function checkDue(db: D1Database, now = Date.now()): Promise<number> { | |
| 318 | if (new Date(now).getUTCMinutes() >= 5) return 0; | |
| 319 | const due = await db | |
| 320 | .prepare( | |
| 321 | `SELECT DISTINCT s.workspace_id FROM agent_sessions s | |
| 322 | LEFT JOIN agent_recommendation_checks c ON c.workspace_id = s.workspace_id | |
| 323 | WHERE s.finished_at >= ?1 AND s.effort IS NOT NULL AND (c.checked_at IS NULL OR c.checked_at < ?2) | |
| 324 | LIMIT ?3`, | |
| 325 | ) | |
| 326 | .bind(iso(now - WINDOW_DAYS * DAY_MS), iso(now - CHECK_EVERY_DAYS * DAY_MS), PER_RUN) | |
| 327 | .all<{ workspace_id: string }>(); | |
| 328 | let checked = 0; | |
| 329 | for (const { workspace_id } of due.results) { | |
| 330 | try { | |
| 331 | await checkWorkspace(db, workspace_id, now); | |
| 332 | checked++; | |
| 333 | } catch (error) { | |
| 334 | console.error("agents: the spend check failed for a workspace", workspace_id, String(error)); | |
| 335 | } | |
| 336 | } | |
| 337 | return checked; | |
| 338 | } | |
| 339 | ||
| 340 | /** What the Spend page shows: open suggestions, thin notes, and what was decided lately. */ | |
| 341 | export async function readRecommendations(db: D1Database, workspaceId: string, agentId: string | null, now = Date.now()): Promise<AgentRecommendations> { | |
| 342 | const [rows, check] = await Promise.all([ | |
| 343 | db | |
| 344 | .prepare( | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 345 | `SELECT r.*, a.handle, a.display_name, a.avatar_seed, a.look FROM agent_recommendations r JOIN agents a ON a.id = r.agent_id |
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 346 | WHERE r.workspace_id = ?1 AND a.archived_at IS NULL AND (?2 IS NULL OR r.agent_id = ?2) |
| 347 | AND (r.status IN ('open', 'thin') OR (r.status IN ('applied', 'dismissed') AND r.resolved_at >= ?3)) | |
| 348 | ORDER BY r.saving_month_micros DESC, a.handle`, | |
| 349 | ) | |
| 350 | .bind(workspaceId, agentId, iso(now - 30 * DAY_MS)) | |
| 351 | .all<RecommendationRow>(), | |
| 352 | db.prepare("SELECT checked_at FROM agent_recommendation_checks WHERE workspace_id = ?").bind(workspaceId).first<{ checked_at: string }>(), | |
| 353 | ]); | |
| 354 | const all = rows.results.map(toRecommendation); | |
| 355 | return { | |
| 356 | checked_at: check?.checked_at ?? null, | |
| 357 | window_days: WINDOW_DAYS, | |
| 358 | open: all.filter((r) => r.status === "open"), | |
| 359 | thin: all.filter((r) => r.status === "thin"), | |
| 360 | resolved: all.filter((r) => r.status === "applied" || r.status === "dismissed").sort((a, b) => (b.resolved_at ?? "").localeCompare(a.resolved_at ?? "")), | |
| 361 | }; | |
| 362 | } | |
| 363 | ||
| 364 | export async function recommendationRow(db: D1Database, workspaceId: string, id: string): Promise<RecommendationRow | null> { | |
| 365 | return db | |
| 366 | .prepare( | |
| Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent. | 367 | `SELECT r.*, a.handle, a.display_name, a.avatar_seed, a.look FROM agent_recommendations r JOIN agents a ON a.id = r.agent_id |
| Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how. | 368 | WHERE r.workspace_id = ? AND r.id = ?`, |
| 369 | ) | |
| 370 | .bind(workspaceId, id) | |
| 371 | .first<RecommendationRow>(); | |
| 372 | } | |
| 373 | ||
| 374 | /** Marks one decided, only from open: two owners pressing at once decide it once. */ | |
| 375 | export async function markResolved(db: D1Database, id: string, status: "applied" | "dismissed", by: string, now = Date.now()): Promise<boolean> { | |
| 376 | const result = await db | |
| 377 | .prepare("UPDATE agent_recommendations SET status = ?, resolved_by = ?, resolved_at = ? WHERE id = ? AND status = 'open'") | |
| 378 | .bind(status, by, iso(now), id) | |
| 379 | .run(); | |
| 380 | return (result.meta.changes ?? 0) > 0; | |
| 381 | } | |
| 382 | ||
| 383 | export function sinceWindow(now = Date.now()): string { | |
| 384 | return iso(now - WINDOW_DAYS * DAY_MS); | |
| 385 | } |