Skip to content
653 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.

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