Skip to content
677 linesCodeBlameRaw
1/**
2 * An agent's abilities (docs.g1t.sh/guides/agent-abilities/): what it can
3 * do, in groups, and for each whether it does it on its own, only when the
4 * person asked for it, after asking first, or never.
5 *
6 * - **g1t's own** (artifacts, chat, code, issues and pull requests, files,
7 * colleagues) are always on, within the access of the person it acts
8 * for. The four that write code and docs keep the agent's `autonomy`
9 * choices, shown here as levels.
10 * - **Its computer**: a shell and files on a computer of its own, which
11 * it uses in sessions (docs.g1t.sh/guides/agents/, "Its computer"); a
12 * browser and web search are coming.
13 * - **Integrations**: every connector the workspace has connected lists
14 * what an agent can do through it, one row per action, from the
15 * connector catalog (./connectors.ts) and what the integrations service
16 * does today. Each row says whose connection it runs on: the
17 * workspace's, or the asking person's own.
18 * - **MCP servers** an owner adds: each tool the server lists is a row. A
19 * tool that doesn't say it only reads is treated as a write.
20 *
21 * Levels default by what a thing does: reading is alone; writing inside
22 * g1t is alone when the person asked for it; sending outside g1t asks
23 * first; purchases, credentials and permission changes are never, and
24 * can't be raised above asking.
25 *
26 * The agents service enforces these in code when it offers tools and when
27 * one is called; the Abilities tab and the agents service both resolve an
28 * agent's choices with `resolveAbilities`, handing it the connector catalog
29 * (./connectors.ts `CONNECTORS`): no value imports here, so services test
30 * it under Node as it is. Wire shapes are snake_case.
31 */
32import type { Connector } from "./connectors";
33import type { AgentAutonomy } from "./workspace-agents";
34
35/** Alone; alone when the asker asked for it; ask first; never. */
36export type AbilityLevel = "alone" | "asked" | "ask" | "never";
37
38export const ABILITY_LEVELS: readonly AbilityLevel[] = ["alone", "asked", "ask", "never"];
39
40export const ABILITY_LEVEL_LABELS: Record<AbilityLevel, string> = {
41 alone: "Alone",
42 asked: "Alone when asked for it",
43 ask: "Ask first",
44 never: "Never",
45};
46
47/** How much a level lets through: a lower number is freer. */
48const ORDER: Record<AbilityLevel, number> = { alone: 0, asked: 1, ask: 2, never: 3 };
49
50export type AbilityGroup = "g1t" | "computer" | "integration" | "mcp";
51
52/**
53 * What an ability does: `read`, `write` (inside g1t), `send` (something
54 * leaves g1t: a comment, a message, a change in another system) or
55 * `restricted` (purchases, credentials, permission changes).
56 */
57export type AbilityKind = "read" | "write" | "send" | "restricted";
58
59/** Whose connection an integration ability runs on. */
60export type AbilityCredentials = "workspace" | "asker";
61
62export type AbilityStatus = "ready" | "coming";
63
64/** The level a kind starts at. */
65export function defaultLevel(kind: AbilityKind): AbilityLevel {
66 switch (kind) {
67 case "read":
68 return "alone";
69 case "write":
70 return "asked";
71 case "send":
72 return "ask";
73 case "restricted":
74 return "never";
75 }
76}
77
78/** The freest level a kind may be set to: restricted things never go above asking. */
79export function maxLevel(kind: AbilityKind): AbilityLevel {
80 return kind === "restricted" ? "ask" : "alone";
81}
82
83/** Whether `level` is within `max`: no freer than it. */
84export function withinLevel(level: AbilityLevel, max: AbilityLevel): boolean {
85 return ORDER[level] >= ORDER[max];
86}
87
88/** One ability as the catalog defines it. */
89export type AbilityDef = {
90 /** `g1t:artifacts`, `integration:linear:read`, `mcp:<server>:<tool>`, `computer:shell`. */
91 id: string;
92 group: AbilityGroup;
93 label: string;
94 /** What it does, in a line. */
95 about: string;
96 kind: AbilityKind;
97 /** The agent tools it offers (the agents service's names), for the row's chips. */
98 tools: string[];
99 status: AbilityStatus;
100 /**
101 * The agent's `autonomy` field it reads and writes, for g1t's own code
102 * and docs abilities, with the levels that field allows.
103 */
104 autonomy?: { key: keyof AgentAutonomy; levels: AbilityLevel[] } | null;
105 /** The level it starts at, when that is not what its kind would say. */
106 default?: AbilityLevel | null;
107};
108
109/** What an agent keeps for one ability. Absent fields mean the default. */
110export type AbilitySetting = { level?: AbilityLevel | null; credentials?: AbilityCredentials | null };
111
112/** A tool an MCP server lists, as it was found. */
113export type McpTool = {
114 name: string;
115 description: string;
116 /** `read` when the server says the tool only reads; `write` otherwise, which is also what an unknown tool is. */
117 kind: "read" | "write";
118 /** Its arguments, as the server describes them (JSON Schema); what the agent is offered. */
119 input_schema: Record<string, unknown>;
120};
121
122/** An MCP server an owner added to one agent. */
123export type McpServer = {
124 /** `mcp_…`, stable: ability ids are made from it. */
125 id: string;
126 /** Lowercase letters, digits and hyphens: the tools are offered as `<name>__<tool>`. */
127 name: string;
128 /** Its HTTPS address; the host is checked when it is added. */
129 url: string;
130 tools: McpTool[];
131 added_by: string;
132 /** RFC 3339. */
133 added_at: string;
134 /** When its tools were last listed, and what went wrong if they couldn't be. */
135 checked_at: string | null;
136 problem: string | null;
137};
138
139/** What an agent's definition keeps about its abilities. */
140export type AgentAbilities = {
141 /** By ability id: only what differs from the default is kept. */
142 settings: Record<string, AbilitySetting>;
143 mcp_servers: McpServer[];
144};
145
146export const EMPTY_ABILITIES: AgentAbilities = { settings: {}, mcp_servers: [] };
147
148/** What a change to an agent sends for its abilities: the settings, whole. MCP servers have their own calls. */
149export type AgentAbilitiesChange = { settings: Record<string, AbilitySetting> };
150
151/** The most MCP servers one agent has, and the most tools one server lists. */
152export const MAX_MCP_SERVERS = 10;
153export const MAX_MCP_TOOLS = 40;
154
155/** An ability as one agent has it now. */
156export type Ability = AbilityDef & {
157 level: AbilityLevel;
158 /** The freest level it may be set to. */
159 max: AbilityLevel;
160 /** The levels it may be set to, freest first. */
161 choices: AbilityLevel[];
162 /** Whether the level can be changed at all: g1t's own reads are always on. */
163 can_change: boolean;
164 /** For an integration: whose connection it runs on. Null elsewhere. */
165 credentials: AbilityCredentials | null;
166 /** Whether the asking person could connect it themselves (the connector has a personal side that is available). */
167 personal_available: boolean;
168};
169
170/** One source of abilities: a connector, an MCP server, or g1t itself. */
171export type AbilitySource = {
172 id: string;
173 name: string;
174 /** For an integration or an MCP server: whether the workspace has it. */
175 connected: boolean;
176 /** Where it is connected or managed; null when there is nowhere. */
177 href: string | null;
178 /** A line about it when it has no abilities, or something is wrong with it. */
179 note: string | null;
180 abilities: Ability[];
181};
182
183export type AbilitySection = {
184 group: AbilityGroup;
185 title: string;
186 about: string;
187 sources: AbilitySource[];
188};
189
190/** What a level means for an agent, in its own words. */
191export function levelWords(level: AbilityLevel): string {
192 switch (level) {
193 case "alone":
194 return "on its own";
195 case "asked":
196 return "only when the person asked for that";
197 case "ask":
198 return "after asking first";
199 case "never":
200 return "never";
201 }
202}
203
204// g1t's own ---------------------------------------------------------------
205
206/** g1t's own abilities: always on, within the asker's access; the four autonomy ones keep their choices. */
207export const G1T_ABILITIES: AbilityDef[] = [
208 {
209 id: "g1t:artifacts",
210 group: "g1t",
211 label: "Artifacts",
212 about: "Search, read, write, edit and share the workspace's artifacts, where the person it acts for can.",
213 kind: "read",
214 tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact"],
215 status: "ready",
216 },
217 {
218 id: "g1t:chat",
219 group: "g1t",
220 label: "Chat",
221 about: "Search messages and read threads everyone in the conversation can read, and the roster.",
222 kind: "read",
223 tools: ["search_messages", "read_thread", "workspace_roster"],
224 status: "ready",
225 },
226 {
227 id: "g1t:code",
228 group: "g1t",
229 label: "Code",
230 about: "Read files and search code in repositories everyone in the conversation can read.",
231 kind: "read",
232 tools: ["list_repositories", "search_code", "read_file"],
233 status: "ready",
234 },
235 {
236 id: "g1t:issues",
237 group: "g1t",
238 label: "Issues and pull requests",
239 about: "Read issues and pull requests, draft issues as cards, and comment or review on behalf of the person who asked.",
240 kind: "read",
241 tools: ["list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"],
242 status: "ready",
243 },
244 {
245 id: "g1t:pulls",
246 group: "g1t",
247 label: "Open pull requests",
248 about: "Open pull requests from its sessions. Rules, protected branches and required checks still apply.",
249 kind: "write",
250 tools: [],
251 status: "ready",
252 autonomy: { key: "open_pull_requests", levels: ["alone", "ask"] },
253 },
254 {
255 id: "g1t:merge",
256 group: "g1t",
257 label: "Merge",
258 about: "Merge pull requests that have what the branch requires.",
259 kind: "write",
260 tools: [],
261 status: "ready",
262 autonomy: { key: "merge", levels: ["alone", "ask", "never"] },
263 },
264 {
265 id: "g1t:deploy",
266 group: "g1t",
267 label: "Deploy to production",
268 about: "Deploy a project's production environment.",
269 kind: "restricted",
270 tools: [],
271 status: "ready",
272 autonomy: { key: "deploy_production", levels: ["ask", "never"] },
273 },
274 {
275 id: "g1t:docs",
276 group: "g1t",
277 label: "Edit docs",
278 about: "Change docs directly, or leave each change as a suggestion someone accepts.",
279 kind: "write",
280 tools: ["edit_artifact"],
281 status: "ready",
282 autonomy: { key: "edit_docs", levels: ["alone", "ask"] },
283 },
284 {
285 id: "g1t:files",
286 group: "g1t",
287 label: "Make files",
288 about: "Make PDFs, Word documents, spreadsheets, CSVs and Markdown files, kept with a doc the person can open.",
289 kind: "write",
290 tools: ["make_file"],
291 status: "ready",
292 },
293 {
294 id: "g1t:colleagues",
295 group: "g1t",
296 label: "Colleagues and memory",
297 about: "Ask a colleague, hand work off, bring one into a session, use its subagents, start sessions, and remember facts.",
298 kind: "write",
299 tools: ["ask_colleague", "hand_off", "bring_in", "use_subagent", "start_session", "post_update", "remember", "forget", "use_skill"],
300 status: "ready",
301 },
302];
303
304/** `autonomy` values as levels, and back. */
305export function levelOfAutonomy(value: string): AbilityLevel {
306 switch (value) {
307 case "alone":
308 return "alone";
309 case "never":
310 return "never";
311 default:
312 // `approval` and `suggest` both mean a person acts first.
313 return "ask";
314 }
315}
316
317export function autonomyOfLevel(key: keyof AgentAutonomy, level: AbilityLevel): string {
318 if (level === "alone") return "alone";
319 if (level === "never") return "never";
320 return key === "edit_docs" ? "suggest" : "approval";
321}
322
323// Its computer -----------------------------------------------------------
324
325/**
326 * The agent's own computer: a persistent home on g1t cloud that its
327 * sessions use and that sleeps when idle. Running commands is a write it
328 * does alone when asked for it; reading and writing its own files is alone,
329 * since nothing leaves the computer. Chat replies never use it.
330 */
331export const COMPUTER_ABILITIES: AbilityDef[] = [
332 { id: "computer:shell", group: "computer", label: "Shell", about: "Run commands on its own computer, with a persistent home, from its sessions.", kind: "write", tools: ["run_command"], status: "ready" },
333 { id: "computer:files", group: "computer", label: "Files", about: "Read and write files in its computer's home.", kind: "write", tools: ["computer_read_file", "computer_write_file"], status: "ready", default: "alone" },
334 { id: "computer:browser", group: "computer", label: "Browser", about: "Open sites in a browser of its own, signed in where you let it be.", kind: "send", tools: [], status: "coming" },
335 { id: "computer:web", group: "computer", label: "Web search", about: "Search and read the open web, as its team's web access allows.", kind: "read", tools: [], status: "coming" },
336];
337
338/** The tools the computer offers, by ability id: what a session offers once the ability is not Never. */
339export const COMPUTER_TOOLS: Record<string, string[]> = Object.fromEntries(COMPUTER_ABILITIES.filter((def) => def.status === "ready").map((def) => [def.id, def.tools]));
340
341// Integrations -----------------------------------------------------------
342
343/**
344 * What an agent can do through each connector today, as the integrations
345 * service does it: look an item up by its key or address, open an issue in
346 * g1t from it, comment on it there, and (Sentry) mark it resolved. A
347 * connector not named here has nothing an agent calls: Datadog and the
348 * alerts webhook open issues by themselves; GitHub imports and mirrors
349 * repositories; model providers run models.
350 */
351const INTEGRATION_ACTIONS: Record<string, { action: string; label: string; about: string; kind: AbilityKind; tool: string }[]> = {
352 linear: [
353 { action: "read", label: "Read issues", about: "Look up an issue by its key (ENG-42) or address: its title, status and description.", kind: "read", tool: "lookup_outside" },
354 { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Linear issue, linked back to it.", kind: "write", tool: "import_outside" },
355 { action: "comment", label: "Comment", about: "Comment on an issue in Linear, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" },
356 ],
357 jira: [
358 { action: "read", label: "Read tickets", about: "Look up a ticket by its key (TECH-1234) or address: its summary, status and description.", kind: "read", tool: "lookup_outside" },
359 { action: "import", label: "Import tickets", about: "Open an issue in a repository here from a Jira ticket, linked back to it.", kind: "write", tool: "import_outside" },
360 { action: "comment", label: "Comment", about: "Comment on a ticket in Jira, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" },
361 ],
362 sentry: [
363 { action: "read", label: "Read issues", about: "Look up a Sentry issue by its address: its title, status and latest stack trace.", kind: "read", tool: "lookup_outside" },
364 { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Sentry issue, linked back to it.", kind: "write", tool: "import_outside" },
365 { action: "comment", label: "Comment", about: "Leave a note on a Sentry issue, as the workspace's connection.", kind: "send", tool: "act_outside" },
366 { action: "resolve", label: "Resolve issues", about: "Mark a Sentry issue resolved, with a note.", kind: "send", tool: "act_outside" },
367 ],
368};
369
370/** What a connected connector with nothing for an agent to call says. */
371function nothingToCall(connector: Connector): string {
372 switch (connector.category) {
373 case "monitoring":
374 return "Opens issues by itself when something fires. Nothing for an agent to call.";
375 case "ai":
376 return "Runs models. Which ones an agent uses is set under Models on its profile.";
377 case "code":
378 return "Imports and mirrors repositories. Agents read the repositories themselves.";
379 default:
380 return "Nothing for an agent to call yet.";
381 }
382}
383
384/** The abilities one connector gives an agent; empty when it has none. */
385export function integrationAbilities(connectorId: string): AbilityDef[] {
386 return (INTEGRATION_ACTIONS[connectorId] ?? []).map((row) => ({
387 id: `integration:${connectorId}:${row.action}`,
388 group: "integration",
389 label: row.label,
390 about: row.about,
391 kind: row.kind,
392 tools: [row.tool],
393 status: "ready",
394 }));
395}
396
397/** The connectors among `connectors` that give agents abilities, in catalog order. */
398export function connectorsWithAbilities(connectors: Connector[]): Connector[] {
399 return connectors.filter((connector) => (INTEGRATION_ACTIONS[connector.id] ?? []).length > 0);
400}
401
402// MCP servers ------------------------------------------------------------
403
404/** An MCP tool as an ability of its server. */
405export function mcpAbility(server: Pick<McpServer, "id" | "name">, tool: McpTool): AbilityDef {
406 return {
407 id: `mcp:${server.id}:${tool.name}`,
408 group: "mcp",
409 label: tool.name,
410 about: tool.description || (tool.kind === "read" ? "Reads, as the server says." : "The server doesn't say it only reads, so it counts as a write."),
411 // Anything an MCP server changes happens outside g1t.
412 kind: tool.kind === "read" ? "read" : "send",
413 tools: [mcpToolName(server.name, tool.name)],
414 status: "ready",
415 };
416}
417
418/** The tool name an agent calls an MCP tool by: `<server>__<tool>`, in the characters the model API allows. */
419export function mcpToolName(server: string, tool: string): string {
420 const clean = (text: string) => text.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
421 return `${clean(server)}__${clean(tool)}`.slice(0, 64);
422}
423
424/** An MCP server's name as kept: lowercase letters, digits and hyphens, 2 to 32. */
425export function checkMcpName(value: unknown): { ok: true; value: string } | { ok: false; message: string } {
426 const name = typeof value === "string" ? value.trim().toLowerCase() : "";
427 if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return { ok: false, message: "A server's name is 2 to 32 lowercase letters, digits and single hyphens." };
428 return { ok: true, value: name };
429}
430
431/**
432 * An MCP server's address, checked: HTTPS, a public host name (not an
433 * address, not local, not g1t's own), no sign-in in the address.
434 */
435export function checkMcpUrl(value: unknown): { ok: true; value: string } | { ok: false; message: string } {
436 const text = typeof value === "string" ? value.trim() : "";
437 if (!text || text.length > 500) return { ok: false, message: "Give the server's HTTPS address." };
438 let url: URL;
439 try {
440 url = new URL(text);
441 } catch {
442 return { ok: false, message: "That isn't an address." };
443 }
444 if (url.protocol !== "https:") return { ok: false, message: "An MCP server is reached over HTTPS." };
445 if (url.username || url.password) return { ok: false, message: "Don't put a sign-in in the address." };
446 const host = url.hostname.toLowerCase();
447 const ip = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[") || host.includes(":");
448 const local = host === "localhost" || /\.(local|localhost|internal|lan|home|arpa)$/.test(host) || !host.includes(".");
449 if (ip || local) return { ok: false, message: "Name the server by a public host name, not an address or a local name." };
450 if (/(^|\.)(g1t\.sh|g1tusercontent\.com)$/.test(host)) return { ok: false, message: "g1t's own addresses aren't MCP servers to add here." };
451 url.hash = "";
452 return { ok: true, value: url.toString() };
453}
454
455// Resolving --------------------------------------------------------------
456
457export type ResolveInput = {
458 /** The connector catalog (./connectors.ts `CONNECTORS`). */
459 connectors: Connector[];
460 abilities: AgentAbilities | null | undefined;
461 autonomy: AgentAutonomy;
462 /** Connector ids the workspace has connected. */
463 connected: string[];
464 /** Where a connector is set up or managed, by id; absent: nowhere to link. */
465 hrefs?: Record<string, string | null>;
466 /** Whether the agent is a member's personal one: then the workspace's connection isn't its to use unless an owner chose it. */
467 personal?: boolean;
468};
469
470function settingOf(input: ResolveInput, id: string): AbilitySetting {
471 return input.abilities?.settings?.[id] ?? {};
472}
473
474/** A definition as one agent has it: its level, within what the kind allows. */
475export function resolveOne(def: AbilityDef, input: ResolveInput, connected = true): Ability {
476 const setting = settingOf(input, def.id);
477 const max = maxLevel(def.kind);
478 let level: AbilityLevel;
479 let choices: AbilityLevel[];
480 let canChange: boolean;
481 if (def.autonomy) {
482 level = levelOfAutonomy(input.autonomy[def.autonomy.key]);
483 choices = def.autonomy.levels;
484 canChange = true;
485 } else if (def.group === "g1t") {
486 level = "alone";
487 choices = ["alone"];
488 canChange = false;
489 } else if (def.status === "coming") {
490 level = defaultLevel(def.kind);
491 choices = [];
492 canChange = false;
493 } else {
494 const wanted = setting.level ?? def.default ?? defaultLevel(def.kind);
495 level = withinLevel(wanted, max) ? wanted : max;
496 choices = ABILITY_LEVELS.filter((l) => withinLevel(l, max));
497 canChange = true;
498 }
499 const integration = def.group === "integration";
500 const connectorId = integration ? def.id.split(":")[1]! : null;
501 const connector = connectorId ? input.connectors.find((c) => c.id === connectorId) : null;
502 const personalAvailable = !!connector && connector.scopes.includes("personal") && (connector.personal?.status ?? connector.status) === "available";
503 return {
504 ...def,
505 level,
506 max,
507 choices,
508 can_change: canChange && (def.group !== "integration" || connected),
509 credentials: integration ? (setting.credentials ?? (input.personal ? "asker" : "workspace")) : null,
510 personal_available: personalAvailable,
511 };
512}
513
514/** The abilities an agent has, in sections, as the tab shows them and the service enforces them. */
515export function resolveAbilities(input: ResolveInput): AbilitySection[] {
516 const connected = new Set(input.connected.map((id) => id.toLowerCase()));
517 const hrefs = input.hrefs ?? {};
518 const sections: AbilitySection[] = [
519 {
520 group: "g1t",
521 title: "g1t",
522 about: "Always on, within the access of the person it acts for. Code and docs keep the choices below.",
523 sources: [{ id: "g1t", name: "g1t", connected: true, href: null, note: null, abilities: G1T_ABILITIES.map((def) => resolveOne(def, input)) }],
524 },
525 {
526 group: "computer",
527 title: "Its computer",
528 about: "A computer of its own on g1t cloud, used in its sessions: a shell and files now, a browser and web search coming. It sleeps when idle and keeps its home.",
529 sources: [{ id: "computer", name: "Computer", connected: true, href: null, note: null, abilities: COMPUTER_ABILITIES.map((def) => resolveOne(def, input)) }],
530 },
531 ];
532 // Every connected connector, then the ones with abilities that aren't connected yet.
533 const sources: AbilitySource[] = [];
534 for (const connector of input.connectors) {
535 if (connector.status !== "available" || !connector.scopes.includes("workspace")) continue;
536 const is = connected.has(connector.id);
537 const defs = integrationAbilities(connector.id);
538 if (!is && !defs.length) continue;
539 // Nothing an agent calls: not worth a row unless it is connected.
540 if (!defs.length && (connector.id === "webhooks" || connector.id === "ai-gateway")) continue;
541 sources.push({
542 id: connector.id,
543 name: connector.name,
544 connected: is,
545 href: hrefs[connector.id] ?? null,
546 note: defs.length ? null : nothingToCall(connector),
547 abilities: defs.map((def) => resolveOne(def, input, is)),
548 });
549 }
550 sources.sort((a, b) => Number(b.connected) - Number(a.connected));
551 sections.push({ group: "integration", title: "Integrations", about: "What it can do through what the workspace has connected, one row per action, and whose connection each runs on.", sources });
552 const servers = input.abilities?.mcp_servers ?? [];
553 sections.push({
554 group: "mcp",
555 title: "MCP servers",
556 about: "Servers an owner adds. Each tool the server lists is a row; a tool that doesn't say it only reads counts as a write.",
557 sources: servers.map((server) => ({
558 id: server.id,
559 name: server.name,
560 connected: true,
561 href: server.url,
562 note: server.problem ?? (server.tools.length ? null : "It listed no tools."),
563 abilities: server.tools.slice(0, MAX_MCP_TOOLS).map((tool) => resolveOne(mcpAbility(server, tool), input)),
564 })),
565 });
566 return sections;
567}
568
569/** Every ability in the sections, flat. */
570export function allAbilities(sections: AbilitySection[]): Ability[] {
571 return sections.flatMap((section) => section.sources.flatMap((source) => source.abilities));
572}
573
574/** The ability by id, among the sections; null when there is none. */
575export function findAbility(sections: AbilitySection[], id: string): { ability: Ability; source: AbilitySource } | null {
576 for (const section of sections) {
577 for (const source of section.sources) {
578 const ability = source.abilities.find((a) => a.id === id);
579 if (ability) return { ability, source };
580 }
581 }
582 return null;
583}
584
585/**
586 * Whether what the person said asked for this: the item's key (ENG-42), or
587 * the ability's own name, appears in it. For a level of `asked`.
588 */
589export function askedFor(said: string, keys: string[]): boolean {
590 const text = said.toLowerCase();
591 if (!text.trim()) return false;
592 return keys.some((key) => {
593 const needle = key.trim().toLowerCase();
594 return needle.length >= 2 && text.includes(needle);
595 });
596}
597
598/** A list in words: "a", "a and b", "a, b and c". */
599function list(items: string[]): string {
600 if (items.length <= 1) return items.join("");
601 return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
602}
603
604const lower = (text: string) => text.charAt(0).toLowerCase() + text.slice(1);
605
606/**
607 * The agent's abilities in a sentence or two: "Can read Linear issues and
608 * open pull requests on its own; imports Linear issues when asked for it;
609 * asks before commenting in Linear and merging; never deploys to
610 * production." Only what is connected and ready counts.
611 */
612export function abilitiesSummary(sections: AbilitySection[]): string {
613 const by: Record<AbilityLevel, string[]> = { alone: [], asked: [], ask: [], never: [] };
614 for (const section of sections) {
615 for (const source of section.sources) {
616 if (!source.connected) continue;
617 for (const ability of source.abilities) {
618 if (ability.status !== "ready") continue;
619 // g1t's always-on reads go without saying; its choices and everything outside are the point.
620 if (section.group === "g1t" && !ability.autonomy) continue;
621 const what =
622 section.group === "g1t"
623 ? lower(ability.label)
624 : section.group === "computer"
625 ? computerWords(ability.id)
626 : section.group === "mcp"
627 ? `call ${ability.label} on ${source.name}`
628 : `${lower(ability.label)} in ${source.name}`;
629 by[ability.level].push(what);
630 }
631 }
632 }
633 const parts: string[] = [];
634 if (by.alone.length) parts.push(`can ${list(by.alone)} on its own`);
635 if (by.asked.length) parts.push(`${list(by.asked.map(verb))} when asked for it`);
636 if (by.ask.length) parts.push(`asks before ${list(by.ask.map(gerund))}`);
637 if (by.never.length) parts.push(`never ${list(by.never.map(verb))}`);
638 if (!parts.length) return "Reads code, chat, issues and artifacts within the asker's access; nothing outside g1t yet.";
639 const text = parts.join("; ");
640 return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
641}
642
643/** A computer ability as what the agent does with it. */
644function computerWords(id: string): string {
645 return id === "computer:shell" ? "run commands on its computer" : "change files on its computer";
646}
647
648/** "read issues in Linear" → "reads issues in Linear". */
649function verb(what: string): string {
650 const [first, ...rest] = what.split(" ");
651 if (!first) return what;
652 const third = first.endsWith("s") ? first : first.endsWith("y") && !/[aeiou]y$/.test(first) ? `${first.slice(0, -1)}ies` : `${first}s`;
653 return [third, ...rest].join(" ");
654}
655
656/** "comment in Linear" → "commenting in Linear". */
657function gerund(what: string): string {
658 const [first, ...rest] = what.split(" ");
659 if (!first) return what;
660 const ing = first.endsWith("e") && first !== "be" ? `${first.slice(0, -1)}ing` : first.endsWith("ing") ? first : `${first}ing`;
661 return [ing, ...rest].join(" ");
662}
663
664/** The settings with one ability's level or credentials changed, keeping only what differs from the default. */
665export function withSetting(abilities: AgentAbilities | null | undefined, id: string, change: AbilitySetting): AgentAbilities {
666 const base = abilities ?? EMPTY_ABILITIES;
667 const current = { ...(base.settings[id] ?? {}) };
668 if (change.level !== undefined) current.level = change.level;
669 if (change.credentials !== undefined) current.credentials = change.credentials;
670 const next = { ...base.settings };
671 const kept: AbilitySetting = {};
672 if (current.level) kept.level = current.level;
673 if (current.credentials) kept.credentials = current.credentials;
674 if (Object.keys(kept).length) next[id] = kept;
675 else delete next[id];
676 return { settings: next, mcp_servers: base.mcp_servers ?? [] };
677}