| 1 | /** |
| 2 | * How agents' cards look, and what they offer, by state: pure, so it is |
| 3 | * tested on its own (cards.ts acts on them, sessions.ts posts them). |
| 4 | */ |
| 5 | import type { AbilityLevel, MessageCard } from "@g1t/contracts"; |
| 6 | |
| 7 | import { ABILITY_LEVEL_LABELS } from "../../../packages/contracts/src/abilities.ts"; |
| 8 | import type { AbilityRequestRow } from "./abilities.ts"; |
| 9 | import type { DraftRow } from "./cards.ts"; |
| 10 | |
| 11 | /** A draft issue's card: what it would file, and the buttons. */ |
| 12 | export function draftCard(draft: Pick<DraftRow, "id" | "repo" | "title" | "body" | "labels" | "status" | "filed_by" | "number">): MessageCard { |
| 13 | const labels = (() => { |
| 14 | try { |
| 15 | return (JSON.parse(draft.labels || "[]") as string[]).join(", "); |
| 16 | } catch { |
| 17 | return ""; |
| 18 | } |
| 19 | })(); |
| 20 | const fields = [{ label: "Repository", value: draft.repo }, ...(labels ? [{ label: "Labels", value: labels }] : [])]; |
| 21 | const preview = draft.body.length > 900 ? `${draft.body.slice(0, 900)}…` : draft.body; |
| 22 | if (draft.status === "filed" && draft.number) { |
| 23 | const href = `/${draft.repo}/issues/${draft.number}`; |
| 24 | return { |
| 25 | kind: "draft_issue", |
| 26 | title: draft.title, |
| 27 | detail: `Filed as ${draft.repo}#${draft.number}${draft.filed_by ? ` by @${draft.filed_by}` : ""}`, |
| 28 | state: "Filed", |
| 29 | href, |
| 30 | fields, |
| 31 | actions: [{ id: "open", label: "Open issue", href }], |
| 32 | owner: "agents", |
| 33 | ref: draft.id, |
| 34 | }; |
| 35 | } |
| 36 | if (draft.status === "discarded") { |
| 37 | return { kind: "draft_issue", title: draft.title, detail: "Discarded", state: "Discarded", href: null, owner: "agents", ref: draft.id }; |
| 38 | } |
| 39 | return { |
| 40 | kind: "draft_issue", |
| 41 | title: draft.title, |
| 42 | detail: `A draft issue for ${draft.repo}. Filing it files it as you.`, |
| 43 | state: "Draft", |
| 44 | href: null, |
| 45 | body: preview, |
| 46 | fields, |
| 47 | actions: [ |
| 48 | { id: "file", label: "File issue", style: "primary" }, |
| 49 | { id: "discard", label: "Discard" }, |
| 50 | ], |
| 51 | owner: "agents", |
| 52 | ref: draft.id, |
| 53 | }; |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * An Ask-first card (docs.g1t.sh/guides/agent-abilities/, "Ask first"): |
| 58 | * what the agent wants to do, for whom, under which rule, with Allow and |
| 59 | * Deny; then what came of it. |
| 60 | */ |
| 61 | export function abilityCard( |
| 62 | request: Pick<AbilityRequestRow, "id" | "summary" | "status" | "decided_by" | "result" | "ability">, |
| 63 | about: { agent: string; asker: string | null; rule: string; level: AbilityLevel; note: string | null; body: string | null }, |
| 64 | ): MessageCard { |
| 65 | const who = about.asker ? `@${about.asker}` : "the person who asked"; |
| 66 | const fields = [ |
| 67 | { label: "Ability", value: about.rule }, |
| 68 | { label: "Rule", value: ABILITY_LEVEL_LABELS[about.level] }, |
| 69 | ]; |
| 70 | const base = { kind: "ability", title: request.summary, href: null, fields, owner: "agents" as const, ref: request.id }; |
| 71 | switch (request.status) { |
| 72 | case "allowed": |
| 73 | return { ...base, detail: `Allowed by @${request.decided_by ?? "someone"}. ${request.result ?? ""}`.trim(), state: "Done", body: about.body }; |
| 74 | case "failed": |
| 75 | return { ...base, detail: `Allowed by @${request.decided_by ?? "someone"}, but it didn't work: ${request.result ?? "no reason given"}`, state: "Failed", body: about.body }; |
| 76 | case "denied": |
| 77 | return { ...base, detail: `Denied by @${request.decided_by ?? "someone"}.`, state: "Denied" }; |
| 78 | default: |
| 79 | return { |
| 80 | ...base, |
| 81 | detail: `${about.agent} wants to do this for ${who}.${about.note ? ` ${about.note}` : ""} Allowing runs it as you.`, |
| 82 | state: "Waiting", |
| 83 | body: about.body, |
| 84 | actions: [ |
| 85 | { id: "allow", label: "Allow", style: "primary" }, |
| 86 | { id: "deny", label: "Deny" }, |
| 87 | ], |
| 88 | }; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** A Connect card: the ability runs on the asking person's own connection, which they haven't made. One press opens its setup. */ |
| 93 | export function connectCard(about: { connector: string; agent: string; ability: string; asker: string | null; href: string }): MessageCard { |
| 94 | return { |
| 95 | kind: "connect", |
| 96 | title: `Connect ${about.connector} to let ${about.agent} ${about.ability.toLowerCase()} for you`, |
| 97 | detail: `Runs on ${about.asker ? `@${about.asker}'s` : "your"} own ${about.connector} connection, which isn't set up yet.`, |
| 98 | state: "Not connected", |
| 99 | href: null, |
| 100 | body: `${about.ability} in ${about.connector} uses your own account there, not the workspace's. Connect it, then ask ${about.agent} again.`, |
| 101 | actions: [{ id: "connect", label: `Connect ${about.connector}`, style: "primary", href: about.href }], |
| 102 | owner: null, |
| 103 | ref: null, |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * A Request card: the agent lacks something, said plainly, with one press |
| 109 | * to ask the workspace's owners (an integration to connect, through the |
| 110 | * Marketplace's requests; an ability to allow, through a notification). |
| 111 | * An owner gets a link to do it instead. |
| 112 | */ |
| 113 | export function requestCard(about: { |
| 114 | agent: { id: string; handle: string; display_name: string }; |
| 115 | workspace: string; |
| 116 | connector: { id: string; name: string; available: boolean } | null; |
| 117 | ability: { id: string; label: string } | null; |
| 118 | why: string; |
| 119 | status: "open" | "asked" | "done"; |
| 120 | by: string | null; |
| 121 | }): MessageCard { |
| 122 | const need = about.ability ? about.ability.label : (about.connector?.name ?? "something"); |
| 123 | const ref = about.ability ? `ability:${about.agent.id}:${about.ability.id}` : `connector:${about.agent.id}:${about.connector?.id ?? ""}`; |
| 124 | const href = about.ability ? `/${about.workspace}/-/agents/${about.agent.handle}/abilities` : `/${about.workspace}/-/marketplace/integrations/${about.connector?.id ?? ""}`; |
| 125 | const title = about.ability ? `${about.agent.display_name} needs an ability: ${need}` : `${about.agent.display_name} needs ${need} connected`; |
| 126 | if (about.status === "asked") { |
| 127 | return { kind: "request", title, detail: `${about.by ? `@${about.by}` : "Someone"} asked the workspace's owners.`, state: "Asked", href: null, fields: [{ label: "Why", value: about.why }], actions: [{ id: "open", label: "Open", href }], owner: "agents", ref }; |
| 128 | } |
| 129 | if (about.connector && !about.connector.available) { |
| 130 | return { kind: "request", title, detail: `${about.connector.name} can't be connected to a workspace yet.`, state: "Coming", href: null, fields: [{ label: "Why", value: about.why }], owner: null, ref: null }; |
| 131 | } |
| 132 | return { |
| 133 | kind: "request", |
| 134 | title, |
| 135 | detail: about.ability ? `${about.agent.display_name} isn't allowed to ${about.ability.label.toLowerCase()}.` : `${about.connector?.name} isn't connected to this workspace.`, |
| 136 | state: "Needed", |
| 137 | href: null, |
| 138 | body: about.ability ? `An owner changes that on ${about.agent.display_name}'s Abilities tab. Anyone else can ask them here.` : `An owner connects ${about.connector?.name} from the Marketplace. Anyone else can ask them here.`, |
| 139 | fields: [{ label: "Why", value: about.why }], |
| 140 | actions: [ |
| 141 | { id: "ask", label: "Ask the owners", style: "primary" }, |
| 142 | { id: "open", label: about.ability ? "Open Abilities" : "Open in Marketplace", href }, |
| 143 | ], |
| 144 | owner: "agents", |
| 145 | ref, |
| 146 | }; |
| 147 | } |
| 148 | |
| 149 | /** Money as people type it ("5", "$12.50") in micro-dollars, or null. */ |
| 150 | export function parseMoney(input: string | null): number | null { |
| 151 | const text = (input ?? "").trim().replace(/^\$/, "").replace(/,/g, ""); |
| 152 | if (!/^\d+(\.\d{1,2})?$/.test(text)) return null; |
| 153 | const micros = Math.round(Number(text) * 1_000_000); |
| 154 | return micros > 0 ? micros : null; |
| 155 | } |
| 156 | |
| 157 | /** What people can do on a session's card, by where it is. */ |
| 158 | export function sessionActions(status: string, cap: number | null, spent: number, href: string): NonNullable<MessageCard["actions"]> { |
| 159 | const open = { id: "open", label: "Open", href }; |
| 160 | const live = status === "queued" || status === "working" || status === "waiting"; |
| 161 | if (status === "needs_approval") { |
| 162 | const suggested = Math.max(spent + 1_000_000, (cap ?? spent) * 2); |
| 163 | return [ |
| 164 | { |
| 165 | id: "approve", |
| 166 | label: "Approve more", |
| 167 | style: "primary", |
| 168 | input: { kind: "money", label: "New cap", placeholder: "5.00", initial: (suggested / 1_000_000).toFixed(2) }, |
| 169 | }, |
| 170 | { id: "stop", label: "Stop", style: "danger", confirm: "Stop this session and everything under it?" }, |
| 171 | open, |
| 172 | ]; |
| 173 | } |
| 174 | if (live) { |
| 175 | return [ |
| 176 | { id: "steer", label: "Message", input: { kind: "text", label: "Tell it", placeholder: "Also check the mobile app…" } }, |
| 177 | { id: "stop", label: "Stop", style: "danger", confirm: "Stop this session and everything under it?" }, |
| 178 | open, |
| 179 | ]; |
| 180 | } |
| 181 | // Over: carry on with a message (it picks up with its context), or read it. |
| 182 | return [{ id: "steer", label: "Follow up", input: { kind: "text", label: "Ask it to", placeholder: "Now write the release note…" } }, open]; |
| 183 | } |
| 184 | |