Skip to content
184 linesCodeBlameRaw
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 */
5import type { AbilityLevel, MessageCard } from "@g1t/contracts";
6
7import { ABILITY_LEVEL_LABELS } from "../../../packages/contracts/src/abilities.ts";
8import type { AbilityRequestRow } from "./abilities.ts";
9import type { DraftRow } from "./cards.ts";
10
11/** A draft issue's card: what it would file, and the buttons. */
12export 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 */
61export 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. */
93export 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 */
113export 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. */
150export 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. */
158export 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