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.
| Chat and workspace agents: channels, DMs and named agents you talk to | 1 | /** |
| 2 | * An agent's definition: what a new one gets by default, what a change may | |
| 3 | * set, and how a stored row reads. Pure, so it is tested on its own. | |
| 4 | */ | |
| 5 | import type { | |
| 6 | AgentAutonomy, | |
| 7 | AgentBudget, | |
| 8 | AgentRouting, | |
| 9 | NewWorkspaceAgent, | |
| 10 | PersonalityPreset, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 11 | AgentFaces, |
| 12 | SubagentDef, | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 13 | } from "@g1t/contracts"; |
| 14 | ||
| 15 | import { checkHandle } from "./handle.ts"; | |
| 16 | import { isTier, limitsAgree } from "./routing.ts"; | |
| 17 | ||
| 18 | export const PRESETS: PersonalityPreset[] = ["crisp", "friendly", "socratic", "terse"]; | |
| 19 | ||
| 20 | export const DEFAULT_ROUTING: AgentRouting = { floor: null, ceiling: null, providers: [], pinned: null }; | |
| 21 | export const DEFAULT_BUDGET: AgentBudget = { monthly_micros: null, daily_micros: null, task_micros: null }; | |
| 22 | /** What an agent may do alone until someone says otherwise: open pull requests; the rest asks. */ | |
| 23 | export const DEFAULT_AUTONOMY: AgentAutonomy = { | |
| 24 | open_pull_requests: "alone", | |
| 25 | merge: "approval", | |
| 26 | deploy_production: "approval", | |
| 27 | edit_docs: "suggest", | |
| 28 | }; | |
| 29 | /** Tasks at once (docs/WORKSPACE.md, "Decisions to confirm"). */ | |
| 30 | export const DEFAULT_CAPACITY = 3; | |
| 31 | export const MAX_CAPACITY = 10; | |
| 32 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 33 | const LIMITS = { displayName: 64, role: 120, title: 60, department: 40, duty: 160, instructions: 8000, personality: 1000, providers: 10, pinned: 200 }; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 34 | /** $100,000 in millionths: a cap above this is a typo. */ |
| 35 | const MAX_MICROS = 100_000_000_000; | |
| 36 | ||
| 37 | /** Everything a definition holds, complete: what is stored and versioned. */ | |
| 38 | export type Definition = { | |
| 39 | handle: string; | |
| 40 | display_name: string; | |
| 41 | role: string; | |
| 42 | instructions: string; | |
| 43 | personality_preset: PersonalityPreset; | |
| 44 | personality: string; | |
| 45 | routing: AgentRouting; | |
| 46 | budget: AgentBudget; | |
| 47 | autonomy: AgentAutonomy; | |
| 48 | capacity: number; | |
| 49 | template: string | null; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 50 | /** What its generated avatar is drawn from. */ |
| 51 | avatar_seed: string; | |
| 52 | title: string; | |
| 53 | team: string | null; | |
| 54 | department: string; | |
| 55 | responsibilities: string[]; | |
| 56 | subagents: SubagentDef[]; | |
| 57 | faces: AgentFaces; | |
| Workspace agents find, read, write and share the workspace's artifacts with search_artifacts, read_artifact, create_artifact (docs for now), edit_artifact, stale_artifacts, share_artifact and list_spaces, and their recall reads artifacts and projects' docs through the folio RPCs instead of Docs' pages. | 58 | /** Spaces whose artifacts it reads first. */ |
| Agents recall what Docs say before they answer or work, and each has required reading | 59 | reading: string[]; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 60 | }; |
| 61 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 62 | /** The one-line role a title and team (or department) make: "QA Engineer on the qa team". */ |
| 63 | export function roleOf(d: Pick<Definition, "title" | "team" | "department">): string { | |
| 64 | const title = d.title.trim(); | |
| 65 | if (!title) return ""; | |
| 66 | if (d.team) return `${title} on the ${d.team} team`; | |
| 67 | return d.department.trim() ? `${title}, ${d.department.trim()}` : title; | |
| 68 | } | |
| 69 | ||
| 70 | export const MAX_SUBAGENTS = 8; | |
| 71 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 72 | export type Checked<T> = { ok: true; value: T } | { ok: false; message: string }; |
| 73 | ||
| 74 | const bad = (message: string): { ok: false; message: string } => ({ ok: false, message }); | |
| 75 | ||
| 76 | function text(value: unknown, what: string, max: number, required: boolean): Checked<string> { | |
| 77 | if (value === undefined || value === null) return required ? bad(`${what} is required.`) : { ok: true, value: "" }; | |
| 78 | if (typeof value !== "string") return bad(`${what} is text.`); | |
| 79 | const trimmed = value.trim(); | |
| 80 | if (required && !trimmed) return bad(`${what} is required.`); | |
| 81 | if (trimmed.length > max) return bad(`${what} is at most ${max} characters.`); | |
| 82 | return { ok: true, value: trimmed }; | |
| 83 | } | |
| 84 | ||
| 85 | function routingOf(base: AgentRouting, given: unknown): Checked<AgentRouting> { | |
| 86 | if (given === undefined || given === null) return { ok: true, value: base }; | |
| 87 | if (typeof given !== "object") return bad("Routing is an object."); | |
| 88 | const g = given as Partial<AgentRouting>; | |
| 89 | const next: AgentRouting = { ...base }; | |
| 90 | for (const key of ["floor", "ceiling"] as const) { | |
| 91 | if (g[key] === undefined) continue; | |
| 92 | if (g[key] !== null && !isTier(g[key])) return bad(`The ${key} is small, large, frontier or none.`); | |
| 93 | next[key] = g[key] ?? null; | |
| 94 | } | |
| 95 | if (!limitsAgree(next.floor, next.ceiling)) return bad("The floor is above the ceiling: lower the floor or raise the ceiling."); | |
| 96 | if (g.providers !== undefined) { | |
| 97 | if (!Array.isArray(g.providers) || g.providers.some((p) => typeof p !== "string" || !p.trim())) return bad("Providers is a list of names."); | |
| 98 | const providers = [...new Set(g.providers.map((p) => p.trim()))]; | |
| 99 | if (providers.length > LIMITS.providers) return bad(`An agent names at most ${LIMITS.providers} providers.`); | |
| 100 | next.providers = providers; | |
| 101 | } | |
| 102 | if (g.pinned !== undefined) { | |
| 103 | if (g.pinned === null || g.pinned === "") next.pinned = null; | |
| 104 | else if (typeof g.pinned !== "string" || g.pinned.trim().length > LIMITS.pinned || !/^[^/\s]+\/\S+$/.test(g.pinned.trim())) { | |
| 105 | return bad("A pinned model is written provider/model."); | |
| 106 | } else next.pinned = g.pinned.trim(); | |
| 107 | } | |
| 108 | return { ok: true, value: next }; | |
| 109 | } | |
| 110 | ||
| 111 | function budgetOf(base: AgentBudget, given: unknown): Checked<AgentBudget> { | |
| 112 | if (given === undefined || given === null) return { ok: true, value: base }; | |
| 113 | if (typeof given !== "object") return bad("Budget is an object."); | |
| 114 | const g = given as Partial<AgentBudget>; | |
| 115 | const next: AgentBudget = { ...base }; | |
| 116 | for (const key of ["monthly_micros", "daily_micros", "task_micros"] as const) { | |
| 117 | const value = g[key]; | |
| 118 | if (value === undefined) continue; | |
| 119 | if (value === null) next[key] = null; | |
| 120 | else if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_MICROS) { | |
| 121 | return bad("A budget is a whole number of millionths of a dollar, or none."); | |
| 122 | } else next[key] = value; | |
| 123 | } | |
| 124 | return { ok: true, value: next }; | |
| 125 | } | |
| 126 | ||
| 127 | const AUTONOMY_CHOICES: { [K in keyof AgentAutonomy]: AgentAutonomy[K][] } = { | |
| 128 | open_pull_requests: ["alone", "approval"], | |
| 129 | merge: ["alone", "approval", "never"], | |
| 130 | deploy_production: ["approval", "never"], | |
| 131 | edit_docs: ["alone", "suggest"], | |
| 132 | }; | |
| 133 | ||
| 134 | function autonomyOf(base: AgentAutonomy, given: unknown): Checked<AgentAutonomy> { | |
| 135 | if (given === undefined || given === null) return { ok: true, value: base }; | |
| 136 | if (typeof given !== "object") return bad("Autonomy is an object."); | |
| 137 | const next = { ...base } as Record<string, string>; | |
| 138 | for (const [key, choices] of Object.entries(AUTONOMY_CHOICES) as [string, string[]][]) { | |
| 139 | const value = (given as Record<string, unknown>)[key]; | |
| 140 | if (value === undefined) continue; | |
| 141 | if (typeof value !== "string" || !choices.includes(value)) return bad(`${key} is one of ${choices.join(", ")}.`); | |
| 142 | next[key] = value; | |
| 143 | } | |
| 144 | return { ok: true, value: next as AgentAutonomy }; | |
| 145 | } | |
| 146 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 147 | function responsibilitiesOf(given: unknown): Checked<string[]> { |
| 148 | if (!Array.isArray(given) || given.some((d) => typeof d !== "string")) return bad("Responsibilities are a list of short duties."); | |
| 149 | const duties = [...new Set((given as string[]).map((d) => d.trim()).filter(Boolean))]; | |
| 150 | if (duties.length && (duties.length < 2 || duties.length > 8)) return bad("Give 2 to 8 responsibilities, or none yet."); | |
| 151 | if (duties.some((d) => d.length > LIMITS.duty)) return bad(`Each responsibility is at most ${LIMITS.duty} characters.`); | |
| 152 | return { ok: true, value: duties }; | |
| 153 | } | |
| 154 | ||
| 155 | const ORDER = ["small", "large", "frontier"] as const; | |
| 156 | ||
| 157 | /** A subagent's limits held within its agent's: never a lower floor, never a higher ceiling. */ | |
| 158 | export function withinParent( | |
| 159 | sub: { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] }, | |
| 160 | parent: Pick<AgentRouting, "floor" | "ceiling">, | |
| 161 | ): { floor: AgentRouting["floor"]; ceiling: AgentRouting["ceiling"] } { | |
| 162 | const at = (tier: AgentRouting["floor"]) => (tier ? ORDER.indexOf(tier) : -1); | |
| 163 | let floor = sub.floor; | |
| 164 | let ceiling = sub.ceiling; | |
| 165 | if (parent.floor && at(floor) < at(parent.floor)) floor = parent.floor; | |
| 166 | if (parent.ceiling && (!ceiling || at(ceiling) > at(parent.ceiling))) ceiling = parent.ceiling; | |
| 167 | // Held inside, a floor can end above the ceiling: the ceiling, the spending rail, wins. | |
| 168 | if (floor && ceiling && at(floor) > at(ceiling)) floor = ceiling; | |
| 169 | return { floor, ceiling }; | |
| 170 | } | |
| 171 | ||
| 172 | function subagentsOf(given: unknown): Checked<SubagentDef[]> { | |
| 173 | if (!Array.isArray(given)) return bad("Subagents are a list."); | |
| 174 | if (given.length > MAX_SUBAGENTS) return bad(`An agent keeps at most ${MAX_SUBAGENTS} subagents.`); | |
| 175 | const out: SubagentDef[] = []; | |
| 176 | for (const raw of given as Partial<SubagentDef>[]) { | |
| 177 | if (!raw || typeof raw !== "object") return bad("Each subagent has a name, a description and instructions."); | |
| 178 | const name = typeof raw.name === "string" ? raw.name.trim().toLowerCase() : ""; | |
| 179 | if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return bad("A subagent's name is 2 to 32 lowercase letters, digits and single hyphens."); | |
| 180 | if (out.some((sub) => sub.name === name)) return bad(`Two subagents are called ${name}.`); | |
| 181 | const description = text(raw.description, `${name}'s description`, 200, true); | |
| 182 | if (!description.ok) return description; | |
| 183 | const instructions = text(raw.instructions, `${name}'s instructions`, 4000, true); | |
| 184 | if (!instructions.ok) return instructions; | |
| 185 | const routing = raw.routing ?? { floor: null, ceiling: null }; | |
| 186 | for (const tier of [routing.floor, routing.ceiling]) { | |
| 187 | if (tier !== null && tier !== undefined && !isTier(tier)) return bad(`${name}'s limits are small, large, frontier or none.`); | |
| 188 | } | |
| 189 | if (!limitsAgree(routing.floor ?? null, routing.ceiling ?? null)) return bad(`${name}'s floor is above its ceiling.`); | |
| 190 | const parallel = raw.max_parallel ?? 2; | |
| 191 | if (typeof parallel !== "number" || !Number.isInteger(parallel) || parallel < 1 || parallel > 8) return bad(`${name} runs 1 to 8 at once.`); | |
| 192 | out.push({ | |
| 193 | name, | |
| 194 | description: description.value, | |
| 195 | instructions: instructions.value, | |
| 196 | routing: { floor: routing.floor ?? null, ceiling: routing.ceiling ?? null }, | |
| 197 | max_parallel: parallel, | |
| 198 | }); | |
| 199 | } | |
| 200 | return { ok: true, value: out }; | |
| 201 | } | |
| 202 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 203 | /** |
| 204 | * `changes` applied to `base` (a new agent's defaults, or its current | |
| 205 | * definition), checked. `templates` are the template ids an agent may name. | |
| 206 | */ | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 207 | export function applyChanges( |
| 208 | base: Definition | null, | |
| 209 | changes: Partial<NewWorkspaceAgent>, | |
| 210 | templates: string[], | |
| 211 | options: { builtin?: boolean } = {}, | |
| 212 | ): Checked<Definition> { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 213 | if (!changes || typeof changes !== "object") return bad("Send the agent's fields."); |
| 214 | const creating = base === null; | |
| 215 | const from: Definition = base ?? { | |
| 216 | handle: "", | |
| 217 | display_name: "", | |
| 218 | role: "", | |
| 219 | instructions: "", | |
| 220 | personality_preset: "crisp", | |
| 221 | personality: "", | |
| 222 | routing: DEFAULT_ROUTING, | |
| 223 | budget: DEFAULT_BUDGET, | |
| 224 | autonomy: DEFAULT_AUTONOMY, | |
| 225 | capacity: DEFAULT_CAPACITY, | |
| 226 | template: null, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 227 | avatar_seed: "", |
| 228 | title: "", | |
| 229 | team: null, | |
| 230 | department: "", | |
| 231 | responsibilities: [], | |
| 232 | subagents: [], | |
| 233 | faces: "internal", | |
| Agents recall what Docs say before they answer or work, and each has required reading | 234 | reading: [], |
| Chat and workspace agents: channels, DMs and named agents you talk to | 235 | }; |
| 236 | const next: Definition = { ...from }; | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 237 | // Whether the role was made from the title and team, so it follows them. |
| 238 | const roleDerived = !from.role || from.role === roleOf(from); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 239 | if (creating || changes.handle !== undefined) { |
| 240 | const handle = checkHandle(changes.handle); | |
| 241 | if (!handle.ok) return bad(handle.message); | |
| 242 | next.handle = handle.handle; | |
| 243 | } | |
| 244 | const fields = [ | |
| 245 | ["display_name", "A display name", LIMITS.displayName, true], | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 246 | ["role", "The role", LIMITS.role, false], |
| 247 | ["title", "The title", LIMITS.title, false], | |
| 248 | ["department", "The department", LIMITS.department, false], | |
| 249 | // The built-in agent's instructions are added to its fixed job, and may be empty. | |
| 250 | ["instructions", "The instructions", LIMITS.instructions, !options.builtin], | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 251 | ["personality", "The personality", LIMITS.personality, false], |
| 252 | ] as const; | |
| 253 | for (const [key, what, max, required] of fields) { | |
| 254 | if (!creating && changes[key] === undefined) continue; | |
| 255 | const value = text(changes[key], what, max, required); | |
| 256 | if (!value.ok) return value; | |
| 257 | next[key] = value.value; | |
| 258 | } | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 259 | if (changes.team !== undefined) { |
| 260 | if (changes.team === null || changes.team === "") next.team = null; | |
| 261 | else if (typeof changes.team !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(changes.team.trim().toLowerCase())) { | |
| 262 | return bad("A team is named by its slug."); | |
| 263 | } else next.team = changes.team.trim().toLowerCase(); | |
| 264 | } | |
| 265 | if (changes.responsibilities !== undefined) { | |
| 266 | const duties = responsibilitiesOf(changes.responsibilities); | |
| 267 | if (!duties.ok) return duties; | |
| 268 | next.responsibilities = duties.value; | |
| 269 | } | |
| 270 | // A role left empty, or made from the title and team before, follows them. | |
| 271 | if (!next.role || (changes.role === undefined && roleDerived)) next.role = roleOf(next); | |
| 272 | if (!next.role) return bad("Give the agent a title or a one-line role."); | |
| 273 | if (changes.avatar_seed !== undefined) { | |
| 274 | const seed = text(changes.avatar_seed, "The avatar seed", 64, false); | |
| 275 | if (!seed.ok) return seed; | |
| 276 | next.avatar_seed = seed.value; | |
| 277 | } | |
| 278 | // A new face from the handle, unless one was chosen; renaming keeps the face. | |
| 279 | if (!next.avatar_seed) next.avatar_seed = next.handle; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 280 | if (changes.personality_preset !== undefined) { |
| 281 | if (!PRESETS.includes(changes.personality_preset)) return bad(`The personality preset is one of ${PRESETS.join(", ")}.`); | |
| 282 | next.personality_preset = changes.personality_preset; | |
| 283 | } | |
| 284 | const routing = routingOf(next.routing, changes.routing); | |
| 285 | if (!routing.ok) return routing; | |
| 286 | next.routing = routing.value; | |
| 287 | const budget = budgetOf(next.budget, changes.budget); | |
| 288 | if (!budget.ok) return budget; | |
| 289 | next.budget = budget.value; | |
| 290 | const autonomy = autonomyOf(next.autonomy, changes.autonomy); | |
| 291 | if (!autonomy.ok) return autonomy; | |
| 292 | next.autonomy = autonomy.value; | |
| 293 | if (changes.capacity !== undefined) { | |
| 294 | const capacity = changes.capacity; | |
| 295 | if (typeof capacity !== "number" || !Number.isInteger(capacity) || capacity < 1 || capacity > MAX_CAPACITY) { | |
| 296 | return bad(`Capacity is 1 to ${MAX_CAPACITY} tasks at once.`); | |
| 297 | } | |
| 298 | next.capacity = capacity; | |
| 299 | } | |
| 300 | if (changes.template !== undefined) { | |
| 301 | if (changes.template !== null && !templates.includes(changes.template)) return bad("There is no such template."); | |
| 302 | next.template = changes.template; | |
| 303 | } | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 304 | if (changes.subagents !== undefined) { |
| 305 | const subagents = subagentsOf(changes.subagents); | |
| 306 | if (!subagents.ok) return subagents; | |
| 307 | next.subagents = subagents.value; | |
| 308 | } | |
| Agents recall what Docs say before they answer or work, and each has required reading | 309 | if (changes.reading !== undefined) { |
| Workspace agents find, read, write and share the workspace's artifacts with search_artifacts, read_artifact, create_artifact (docs for now), edit_artifact, stale_artifacts, share_artifact and list_spaces, and their recall reads artifacts and projects' docs through the folio RPCs instead of Docs' pages. | 310 | if (!Array.isArray(changes.reading)) return bad("Required reading is a list of spaces."); |
| Agents recall what Docs say before they answer or work, and each has required reading | 311 | const ids = [...new Set(changes.reading.filter((id): id is string => typeof id === "string").map((id) => id.trim()).filter(Boolean))]; |
| 312 | if (ids.length > 10) return bad("An agent has at most 10 spaces of required reading."); | |
| Workspace agents find, read, write and share the workspace's artifacts with search_artifacts, read_artifact, create_artifact (docs for now), edit_artifact, stale_artifacts, share_artifact and list_spaces, and their recall reads artifacts and projects' docs through the folio RPCs instead of Docs' pages. | 313 | if (ids.some((id) => !/^[A-Za-z0-9_-]{1,80}$/.test(id))) return bad("That isn't a space."); |
| Agents recall what Docs say before they answer or work, and each has required reading | 314 | next.reading = ids; |
| 315 | } | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 316 | if (changes.faces !== undefined) { |
| 317 | if (changes.faces === "customers") return bad("Customer-facing agents aren't available yet."); | |
| 318 | if (changes.faces !== "internal") return bad("An agent faces internal: the workspace's own people."); | |
| 319 | next.faces = "internal"; | |
| 320 | } | |
| 321 | // Never wider than their agent, whichever of the two changed. | |
| 322 | next.subagents = next.subagents.map((sub) => ({ ...sub, routing: withinParent(sub.routing, next.routing) })); | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 323 | return { ok: true, value: next }; |
| 324 | } | |
| 325 | ||
| 326 | /** A stored JSON column, read defensively: a bad value is the default. */ | |
| 327 | export function readJson<T extends object>(raw: unknown, fallback: T): T { | |
| 328 | if (typeof raw !== "string") return fallback; | |
| 329 | try { | |
| 330 | const parsed = JSON.parse(raw) as unknown; | |
| 331 | return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? { ...fallback, ...(parsed as T) } : fallback; | |
| 332 | } catch { | |
| 333 | return fallback; | |
| 334 | } | |
| 335 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.