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.
| 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) | 1 | /** |
| 2 | * What an agent can read while it replies: code, issues, pull requests, | |
| 3 | * chat, the roster, and its colleagues (docs/WORKSPACE.md, "What an agent | |
| 4 | * can and can't know", "Agents know each other"). | |
| 5 | * | |
| 6 | * Every tool goes through the reply's `Audience` before it reads | |
| 7 | * anything, and the check is here, in code: | |
| 8 | * - code tools are offered only when the audience may read code at all, | |
| 9 | * and a repository is used only when it is on the audience's allow-list; | |
| 10 | * - chat tools ask the chat service, which works out the audience from the | |
| 11 | * conversation itself; | |
| 12 | * - what the audience may not see comes back as one neutral line, | |
| 13 | * `WITHHELD`, the same for a thing that is private and a thing that does | |
| 14 | * not exist, and never names it. | |
| 15 | * | |
| 16 | * Whatever a tool returns is wrapped as untrusted data: text in files, | |
| 17 | * issues and messages is never an instruction to the agent. | |
| 18 | * | |
| 19 | * Pure apart from its ports, so the rules are tested adversarially. | |
| 20 | */ | |
| Docs: a workspace knowledge base people and agents write together | 21 | import type { DocAudience, DocEditTarget, User } from "@g1t/contracts"; |
| 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) | 22 | |
| 23 | import { type Audience, type RepoRef, WITHHELD } from "./audience.ts"; | |
| 24 | ||
| 25 | /** One tool, as the Messages API takes it. */ | |
| 26 | export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> }; | |
| 27 | ||
| 28 | export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string }; | |
| 29 | ||
| 30 | /** What the tools reach outside this module. */ | |
| 31 | export interface ToolPorts { | |
| 32 | readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>; | |
| 33 | searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>; | |
| 34 | listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>; | |
| 35 | getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>; | |
| 36 | getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>; | |
| 37 | recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>; | |
| 38 | /** Chat's own audience rule applies; null when the search failed. */ | |
| 39 | searchMessages(query: string): Promise<FoundMessage[] | null>; | |
| 40 | /** Null when the audience may not read it (or it does not exist). */ | |
| 41 | readThread(channelId: string, id: string): Promise<FoundMessage[] | null>; | |
| 42 | roster(viewer: User | null): Promise<string>; | |
| 43 | consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>; | |
| Docs: a workspace knowledge base people and agents write together | 44 | /** |
| 45 | * The workspace's Docs, as the docs service lets this agent use them for | |
| 46 | * the person it acts for and everyone who will read the answer. Absent | |
| 47 | * where there is no docs service. | |
| 48 | */ | |
| 49 | docs?: DocsPorts; | |
| 50 | } | |
| 51 | ||
| 52 | /** Docs, as an agent uses them. Every call names the person it acts for and who reads the answer; the docs service checks both. */ | |
| 53 | export interface DocsPorts { | |
| 54 | spaces(viewer: User, audience: DocAudience): Promise<string | null>; | |
| 55 | search(viewer: User, audience: DocAudience, query: string, project: string | null): Promise<string | null>; | |
| 56 | read(viewer: User, audience: DocAudience, pageId: string): Promise<string | null>; | |
| 57 | edit(viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note: string | null }, suggestOnly: boolean): Promise<{ ok: boolean; message: string }>; | |
| 58 | create(viewer: User, input: { space_id: string | null; parent_id: string | null; title: string; markdown: string; source: { title: string; href: string } | null }): Promise<{ ok: boolean; message: string }>; | |
| 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) | 59 | } |
| 60 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 61 | /** |
| 62 | * What an agent may do, beyond reading: remember, file an issue for the | |
| 63 | * person who asked, and start or shape work. Each is checked here before it | |
| 64 | * runs (the audience, the asker, the hop limit) and again by the service | |
| 65 | * that does it. | |
| 66 | */ | |
| 67 | export interface ActionPorts { | |
| 68 | remember(body: string, scope: "workspace" | "channel" | "person" | null): Promise<{ ok: boolean; message: string }>; | |
| 69 | forget(id: string): Promise<{ ok: boolean; message: string }>; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 70 | /** |
| 71 | * Posts a draft issue as a card in the conversation, with File issue and | |
| 72 | * Discard: whoever presses File files it as themselves, if they can read | |
| 73 | * the repository. Nothing is filed by the agent. | |
| 74 | */ | |
| 75 | draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>; | |
| 76 | /** | |
| 77 | * Comments on an issue or pull request, or reviews a pull request, as the | |
| 78 | * agent on behalf of the person who asked. Reviews are advisory: they | |
| 79 | * never count toward required approvals. | |
| 80 | */ | |
| 81 | comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>; | |
| 82 | review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 83 | /** From chat: spins off a session for real work. */ |
| 84 | startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>; | |
| 85 | /** In a session: a short progress note in its thread. */ | |
| 86 | postUpdate?(text: string): Promise<{ ok: boolean; message: string }>; | |
| 87 | /** In a session: one of the agent's own subagents takes part of the work. */ | |
| 88 | useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>; | |
| 89 | /** In a session: a colleague works on part of it, paid from this session's budget. */ | |
| 90 | bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>; | |
| 91 | } | |
| 92 | ||
| 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) | 93 | /** The most tool calls one reply makes. */ |
| 94 | export const MAX_TOOL_CALLS = 8; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 95 | /** The most tool calls one step of a session makes. */ |
| 96 | export const MAX_SESSION_TOOL_CALLS = 24; | |
| 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) | 97 | /** The most of a file or result an answer is given, in characters. */ |
| 98 | const MAX_RESULT = 20_000; | |
| 99 | ||
| 100 | export type ToolCall = { | |
| 101 | tool: string; | |
| 102 | /** Its arguments, with long text cut, as recorded. */ | |
| 103 | args: string; | |
| 104 | outcome: "allowed" | "withheld" | "refused" | "error"; | |
| 105 | bytes: number; | |
| 106 | }; | |
| 107 | ||
| 108 | export type ToolResult = { text: string; outcome: ToolCall["outcome"] }; | |
| 109 | ||
| 110 | /** | |
| 111 | * Text a tool read, marked as data. Anything in it that looks like the | |
| 112 | * closing mark is defused, so content can't end the block early. | |
| 113 | */ | |
| 114 | export function untrusted(source: string, content: string): string { | |
| 115 | const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "<")); | |
| 116 | const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content; | |
| 117 | return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`; | |
| 118 | } | |
| 119 | ||
| 120 | /** Arguments as recorded: every string cut to 120 characters. */ | |
| 121 | export function redact(args: unknown): string { | |
| 122 | const cut = (value: unknown): unknown => { | |
| 123 | if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value; | |
| 124 | if (Array.isArray(value)) return value.slice(0, 10).map(cut); | |
| 125 | if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)])); | |
| 126 | return value; | |
| 127 | }; | |
| 128 | return JSON.stringify(cut(args ?? {})).slice(0, 1000); | |
| 129 | } | |
| 130 | ||
| 131 | const CODE_TOOLS: ToolDef[] = [ | |
| 132 | { | |
| 133 | name: "list_repositories", | |
| 134 | description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.", | |
| 135 | input_schema: { type: "object", properties: {} }, | |
| 136 | }, | |
| 137 | { | |
| 138 | name: "search_code", | |
| 139 | description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).", | |
| 140 | input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] }, | |
| 141 | }, | |
| 142 | { | |
| 143 | name: "read_file", | |
| 144 | description: "Read a file from a repository, at its default branch or a ref.", | |
| 145 | input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] }, | |
| 146 | }, | |
| 147 | { | |
| 148 | name: "list_issues", | |
| 149 | description: "A repository's newest issues, open by default.", | |
| 150 | input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] }, | |
| 151 | }, | |
| 152 | { | |
| 153 | name: "get_issue", | |
| 154 | description: "One issue with its comments.", | |
| 155 | input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] }, | |
| 156 | }, | |
| 157 | { | |
| 158 | name: "get_pull", | |
| 159 | description: "One pull request: what it changes, its status and checks.", | |
| 160 | input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] }, | |
| 161 | }, | |
| 162 | { | |
| 163 | name: "recent_activity", | |
| 164 | description: "Recently merged and open pull requests, in one repository or across those you can read.", | |
| 165 | input_schema: { type: "object", properties: { repo: { type: "string" } } }, | |
| 166 | }, | |
| 167 | ]; | |
| 168 | ||
| 169 | const CHAT_TOOLS: ToolDef[] = [ | |
| 170 | { | |
| 171 | name: "search_messages", | |
| 172 | description: "Search chat messages this conversation's people can all read.", | |
| 173 | input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, | |
| 174 | }, | |
| 175 | { | |
| 176 | name: "read_thread", | |
| 177 | description: "Read a chat thread by its channel id and a message id in it (from search_messages).", | |
| 178 | input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] }, | |
| 179 | }, | |
| 180 | { | |
| 181 | name: "workspace_roster", | |
| 182 | description: "The workspace's people and agents: names, teams, titles and roles.", | |
| 183 | input_schema: { type: "object", properties: {} }, | |
| 184 | }, | |
| 185 | ]; | |
| 186 | ||
| 187 | const ASK_COLLEAGUE: ToolDef = { | |
| 188 | name: "ask_colleague", | |
| 189 | description: | |
| 190 | "Ask another agent of the workspace a question and get their answer here, without handing the work over. Use it when their role knows something yours doesn't.", | |
| 191 | input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] }, | |
| 192 | }; | |
| 193 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 194 | const REMEMBER: ToolDef = { |
| 195 | name: "remember", | |
| 196 | description: | |
| 197 | "Keep a short fact for later work: a preference, a decision, who owns what, how something works here. One fact per call, in your own words. It is kept where this conversation allows (this person, this conversation, or the workspace from a public channel), with this conversation as its source. Never keep secrets, credentials or customers' personal data.", | |
| 198 | input_schema: { | |
| 199 | type: "object", | |
| 200 | properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } }, | |
| 201 | required: ["fact"], | |
| 202 | }, | |
| 203 | }; | |
| 204 | ||
| 205 | const FORGET: ToolDef = { | |
| 206 | name: "forget", | |
| 207 | description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.", | |
| 208 | input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, | |
| 209 | }; | |
| 210 | ||
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 211 | const DRAFT_ISSUE: ToolDef = { |
| 212 | name: "draft_issue", | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 213 | description: |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 214 | "Draft an issue (a bug report or a feature request) for a repository with what you found. It appears in the conversation as a card with File issue and Discard buttons: the person files it themselves with one press, so don't ask them to confirm in words. Write it for the team that will fix it: what happens, what should happen, steps or evidence, and where in the code it likely is.", |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 215 | input_schema: { |
| 216 | type: "object", | |
| 217 | properties: { | |
| 218 | repo: { type: "string" }, | |
| 219 | title: { type: "string" }, | |
| 220 | body: { type: "string" }, | |
| 221 | labels: { type: "array", items: { type: "string" } }, | |
| 222 | }, | |
| 223 | required: ["repo", "title", "body"], | |
| 224 | }, | |
| 225 | }; | |
| 226 | ||
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 227 | const COMMENT: ToolDef = { |
| 228 | name: "comment", | |
| 229 | description: | |
| 230 | "Comment on an issue or pull request, as yourself on behalf of the person you're working for. Use it when the comment belongs on the issue or pull request (findings, a test plan, a question for its author), not for chatting.", | |
| 231 | input_schema: { | |
| 232 | type: "object", | |
| 233 | properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } }, | |
| 234 | required: ["repo", "number", "body"], | |
| 235 | }, | |
| 236 | }; | |
| 237 | ||
| 238 | const REVIEW_PULL: ToolDef = { | |
| 239 | name: "review_pull", | |
| 240 | description: | |
| 241 | "Review a pull request on the pull request itself, as yourself on behalf of the person you're working for: approve, request changes, or just comment, with your review in the body. Your review is advisory: people still give the approvals a merge needs. Read the change first.", | |
| 242 | input_schema: { | |
| 243 | type: "object", | |
| 244 | properties: { | |
| 245 | repo: { type: "string" }, | |
| 246 | number: { type: "integer" }, | |
| 247 | verdict: { type: "string", enum: ["comment", "approve", "request_changes"] }, | |
| 248 | body: { type: "string" }, | |
| 249 | }, | |
| 250 | required: ["repo", "number", "verdict", "body"], | |
| 251 | }, | |
| 252 | }; | |
| 253 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 254 | const START_SESSION: ToolDef = { |
| 255 | name: "start_session", | |
| 256 | description: | |
| 257 | "Spin off a session for work that needs more than a quick answer: investigating, reading a lot of code, writing something long, or anything that takes several steps. It runs on its own with its own context, shows a live card here, and reports back in this conversation when done. Give it a short title and a complete brief: the goal, what done looks like, and everything it needs from this conversation.", | |
| 258 | input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] }, | |
| 259 | }; | |
| 260 | ||
| 261 | const POST_UPDATE: ToolDef = { | |
| 262 | name: "post_update", | |
| 263 | description: "Post a short progress note in your session's thread, for the people following it. Use it for real milestones or a question, not for every step.", | |
| 264 | input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, | |
| 265 | }; | |
| 266 | ||
| 267 | const USE_SUBAGENT: ToolDef = { | |
| 268 | name: "use_subagent", | |
| 269 | description: | |
| 270 | "Hand a well-defined part of this session to one of your subagents (listed under Subagents). It works in its own session, paid from this one, and its result comes back to you before you go on. Give a complete brief.", | |
| 271 | input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] }, | |
| 272 | }; | |
| 273 | ||
| 274 | const BRING_IN: ToolDef = { | |
| 275 | name: "bring_in", | |
| 276 | description: | |
| 277 | "Bring a colleague in on part of this session when their role owns it. They work in their own session, paid from this one, and their result comes back to you before you go on. Give a complete brief.", | |
| 278 | input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] }, | |
| 279 | }; | |
| 280 | ||
| Docs: a workspace knowledge base people and agents write together | 281 | const DOCS_TOOLS: ToolDef[] = [ |
| 282 | { | |
| 283 | name: "search_docs", | |
| 284 | description: | |
| 285 | "Search the workspace's Docs (specs, runbooks, policies, onboarding, decisions) that everyone in this conversation can read. Optionally only pages about one project (`workspace/name`). Look here first for how things work and what was decided.", | |
| 286 | input_schema: { type: "object", properties: { query: { type: "string" }, project: { type: "string" } }, required: ["query"] }, | |
| 287 | }, | |
| 288 | { | |
| 289 | name: "read_page", | |
| 290 | description: "Read a Docs page as Markdown, with the ids of its top-level blocks (for editing), by its id from search_docs or a link.", | |
| 291 | input_schema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] }, | |
| 292 | }, | |
| 293 | { | |
| 294 | name: "list_doc_spaces", | |
| 295 | description: "The Docs spaces you can read here, with what you may do in each (read, suggest, edit).", | |
| 296 | input_schema: { type: "object", properties: {} }, | |
| 297 | }, | |
| 298 | ]; | |
| 299 | ||
| 300 | const DOCS_WRITE_TOOLS: ToolDef[] = [ | |
| 301 | { | |
| 302 | name: "edit_page", | |
| 303 | description: | |
| 304 | "Change a Docs page: replace a section (by its heading), a range of top-level blocks (ids from read_page), the whole page, or add to the end. Where the space lets agents edit, it applies at once and shows in the page's history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read the page first. Write Markdown.", | |
| 305 | input_schema: { | |
| 306 | type: "object", | |
| 307 | properties: { | |
| 308 | page: { type: "string" }, | |
| 309 | target: { type: "string", enum: ["append", "section", "blocks", "document"] }, | |
| 310 | heading: { type: "string", description: "For target section: the heading's text." }, | |
| 311 | from_block: { type: "string" }, | |
| 312 | to_block: { type: "string" }, | |
| 313 | markdown: { type: "string" }, | |
| 314 | note: { type: "string", description: "Why, in a line, for the history or the suggestion." }, | |
| 315 | suggest_only: { type: "boolean", description: "Suggest even where you could edit." }, | |
| 316 | }, | |
| 317 | required: ["page", "target", "markdown"], | |
| 318 | }, | |
| 319 | }, | |
| 320 | { | |
| 321 | name: "create_page", | |
| 322 | description: | |
| 323 | "Write a new Docs page (\"write this up\"): a title and Markdown, in a space (its id from list_doc_spaces; the General space when left out), optionally under a parent page. Link where it came from when it came from a conversation.", | |
| 324 | input_schema: { | |
| 325 | type: "object", | |
| 326 | properties: { | |
| 327 | title: { type: "string" }, | |
| 328 | markdown: { type: "string" }, | |
| 329 | space: { type: "string" }, | |
| 330 | parent: { type: "string" }, | |
| 331 | source_title: { type: "string" }, | |
| 332 | source_href: { type: "string" }, | |
| 333 | }, | |
| 334 | required: ["title", "markdown"], | |
| 335 | }, | |
| 336 | }, | |
| 337 | ]; | |
| 338 | ||
| 339 | const DOCS_NAMES = new Set([...DOCS_TOOLS, ...DOCS_WRITE_TOOLS].map((tool) => tool.name)); | |
| 340 | ||
| 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) | 341 | const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name)); |
| 342 | ||
| 343 | export type ToolContext = { | |
| 344 | /** The agent replying. */ | |
| 345 | agentId: string; | |
| 346 | /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */ | |
| 347 | notConsult: string[]; | |
| 348 | /** Hops so far: a consult is one more, and none is offered at the limit. */ | |
| 349 | hops: number; | |
| 350 | maxHops: number; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 351 | /** Whether this is a session's step (more calls, session tools) or a reply. */ |
| 352 | session?: boolean; | |
| 353 | /** Told of every call as it is made, for a session's transcript. */ | |
| 354 | onCall?: (call: ToolCall) => void; | |
| 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) | 355 | }; |
| 356 | ||
| 357 | export class ToolBox { | |
| 358 | /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */ | |
| 359 | readonly calls: ToolCall[]; | |
| 360 | private readonly audience: Audience; | |
| 361 | private readonly ports: ToolPorts; | |
| 362 | private readonly context: ToolContext; | |
| 363 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 364 | private readonly actions: ActionPorts | null; |
| 365 | /** Updates posted in this step. */ | |
| 366 | private updates = 0; | |
| 367 | ||
| 368 | constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = 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) | 369 | this.audience = audience; |
| 370 | this.ports = ports; | |
| 371 | this.context = context; | |
| 372 | this.calls = calls; | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 373 | this.actions = actions; |
| 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) | 374 | } |
| 375 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 376 | /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */ |
| 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) | 377 | forColleague(ports: ToolPorts, context: ToolContext): ToolBox { |
| 378 | return new ToolBox(this.audience, ports, context, this.calls); | |
| 379 | } | |
| 380 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 381 | /** The most calls this box makes. */ |
| 382 | get maxCalls(): number { | |
| 383 | return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS; | |
| 384 | } | |
| 385 | ||
| 386 | /** Whether the person who asked can be acted for: resolved, and able to read code here. */ | |
| 387 | private canFile(): boolean { | |
| 388 | return !!this.actions && !!this.audience.asker && this.audience.codeAllowed(); | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * The tools offered: no code tools for an audience that can't read code, | |
| 393 | * no consults or hand-offs at the hop limit, session tools only in a | |
| 394 | * session, and a spin-off only from chat. | |
| 395 | */ | |
| 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) | 396 | definitions(): ToolDef[] { |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 397 | const roomForHop = this.context.hops + 1 <= this.context.maxHops; |
| 398 | const actions = this.actions; | |
| 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) | 399 | return [ |
| 400 | ...(this.audience.codeAllowed() ? CODE_TOOLS : []), | |
| 401 | ...CHAT_TOOLS, | |
| Docs: a workspace knowledge base people and agents write together | 402 | // Docs are for everyone, Code or not: the docs service decides what this person and audience can read. |
| 403 | ...(this.ports.docs && this.audience.asker ? DOCS_TOOLS : []), | |
| 404 | ...(this.ports.docs && this.audience.asker && actions ? DOCS_WRITE_TOOLS : []), | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 405 | ...(roomForHop ? [ASK_COLLEAGUE] : []), |
| 406 | ...(actions ? [REMEMBER, FORGET] : []), | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 407 | ...(this.canFile() ? [DRAFT_ISSUE] : []), |
| 408 | ...(this.canFile() && actions?.comment ? [COMMENT] : []), | |
| 409 | ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []), | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 410 | ...(actions?.startSession && !this.context.session ? [START_SESSION] : []), |
| 411 | ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []), | |
| 412 | ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []), | |
| 413 | ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []), | |
| 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) | 414 | ]; |
| 415 | } | |
| 416 | ||
| 417 | /** Whether another call may be made. */ | |
| 418 | get spent(): boolean { | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 419 | return this.calls.length >= this.maxCalls; |
| 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) | 420 | } |
| 421 | ||
| 422 | async run(name: string, input: Record<string, unknown>): Promise<ToolResult> { | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 423 | const result = await this.attempt(name, input); |
| 424 | const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length }; | |
| 425 | this.calls.push(call); | |
| 426 | this.context.onCall?.(call); | |
| 427 | return result; | |
| 428 | } | |
| 429 | ||
| 430 | private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> { | |
| 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) | 431 | let result: ToolResult; |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 432 | const what = this.context.session ? "step" : "reply"; |
| 433 | if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" }; | |
| 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) | 434 | else { |
| 435 | try { | |
| 436 | result = await this.dispatch(name, input ?? {}); | |
| 437 | } catch (error) { | |
| 438 | console.error("agents: a tool failed", name, String(error)); | |
| 439 | result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" }; | |
| 440 | } | |
| 441 | } | |
| 442 | return result; | |
| 443 | } | |
| 444 | ||
| 445 | private withheld(): ToolResult { | |
| 446 | return { text: WITHHELD, outcome: "withheld" }; | |
| 447 | } | |
| 448 | ||
| 449 | private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> { | |
| 450 | const asker = this.audience.asker; | |
| Docs: a workspace knowledge base people and agents write together | 451 | if (DOCS_NAMES.has(name)) { |
| 452 | if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.docs) return { text: `There is no tool called ${name} here.`, outcome: "refused" }; | |
| 453 | return this.docs(name, input, asker, this.ports.docs); | |
| 454 | } | |
| 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) | 455 | if (CODE_NAMES.has(name)) { |
| 456 | // Not offered, and refused if asked for anyway: the check is here, not in the prompt. | |
| 457 | if (!this.audience.codeAllowed() || !asker) return this.withheld(); | |
| 458 | return this.code(name, input, asker); | |
| 459 | } | |
| 460 | switch (name) { | |
| 461 | case "search_messages": { | |
| 462 | const query = String(input.query ?? "").trim(); | |
| 463 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; | |
| 464 | const found = await this.ports.searchMessages(query); | |
| 465 | if (found === null) return { text: "Search didn't work just now.", outcome: "error" }; | |
| 466 | if (!found.length) return { text: "No messages found.", outcome: "allowed" }; | |
| 467 | return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" }; | |
| 468 | } | |
| 469 | case "read_thread": { | |
| 470 | const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? "")); | |
| 471 | if (!thread || !thread.length) return this.withheld(); | |
| 472 | return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" }; | |
| 473 | } | |
| 474 | case "workspace_roster": | |
| 475 | return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" }; | |
| 476 | case "ask_colleague": { | |
| 477 | const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase(); | |
| 478 | const question = String(input.question ?? "").trim(); | |
| 479 | if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" }; | |
| 480 | if (this.context.hops + 1 > this.context.maxHops) return { text: "This request has been passed along too many times; answer with what you have.", outcome: "refused" }; | |
| 481 | if (this.context.notConsult.includes(handle)) { | |
| 482 | return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" }; | |
| 483 | } | |
| 484 | const answer = await this.ports.consult(handle, question.slice(0, 2000)); | |
| 485 | if (!answer.ok) return { text: answer.message, outcome: "refused" }; | |
| 486 | return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" }; | |
| 487 | } | |
| 488 | default: | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 489 | return this.act(name, input); |
| 490 | } | |
| 491 | } | |
| 492 | ||
| Docs: a workspace knowledge base people and agents write together | 493 | /** Who reads what an agent says here, as the docs service takes it. */ |
| 494 | private docAudience(): DocAudience { | |
| 495 | return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) }; | |
| 496 | } | |
| 497 | ||
| 498 | private async docs(name: string, input: Record<string, unknown>, asker: User, docs: DocsPorts): Promise<ToolResult> { | |
| 499 | const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max); | |
| 500 | const audience = this.docAudience(); | |
| 501 | const read = (source: string, found: string | null): ToolResult => (found === null ? this.withheld() : { text: untrusted(source, found), outcome: "allowed" }); | |
| 502 | switch (name) { | |
| 503 | case "list_doc_spaces": | |
| 504 | return read("list_doc_spaces", await docs.spaces(asker, audience)); | |
| 505 | case "search_docs": { | |
| 506 | const query = text("query", 200); | |
| 507 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; | |
| 508 | const project = text("project", 200).toLowerCase() || null; | |
| 509 | return read(`search_docs "${query}"`, await docs.search(asker, audience, query, project)); | |
| 510 | } | |
| 511 | case "read_page": { | |
| 512 | const page = pageId(text("page", 300)); | |
| 513 | if (!page) return { text: "Give the page's id or link.", outcome: "refused" }; | |
| 514 | return read(`read_page ${page}`, await docs.read(asker, audience, page)); | |
| 515 | } | |
| 516 | case "edit_page": { | |
| 517 | const page = pageId(text("page", 300)); | |
| 518 | const markdown = String(input.markdown ?? "").slice(0, 100_000); | |
| 519 | if (!page || !markdown.trim()) return { text: "Give the page and the Markdown.", outcome: "refused" }; | |
| 520 | const kind = text("target", 20); | |
| 521 | const target: DocEditTarget | null = | |
| 522 | kind === "append" | |
| 523 | ? { kind: "append" } | |
| 524 | : kind === "document" | |
| 525 | ? { kind: "document" } | |
| 526 | : kind === "section" && text("heading", 300) | |
| 527 | ? { kind: "section", heading: text("heading", 300) } | |
| 528 | : kind === "blocks" && text("from_block", 100) && text("to_block", 100) | |
| 529 | ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) } | |
| 530 | : null; | |
| 531 | if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" }; | |
| 532 | const done = await docs.edit(asker, page, { target, markdown, note: text("note", 300) || null }, input.suggest_only === true); | |
| 533 | return { text: done.message, outcome: done.ok ? "allowed" : "refused" }; | |
| 534 | } | |
| 535 | case "create_page": { | |
| 536 | const title = text("title", 200); | |
| 537 | const markdown = String(input.markdown ?? "").slice(0, 100_000); | |
| 538 | if (!title || !markdown.trim()) return { text: "A page needs a title and its Markdown.", outcome: "refused" }; | |
| 539 | const href = text("source_href", 2000); | |
| 540 | const done = await docs.create(asker, { | |
| 541 | space_id: text("space", 100) || null, | |
| 542 | parent_id: pageId(text("parent", 300)), | |
| 543 | title, | |
| 544 | markdown, | |
| 545 | source: href.startsWith("/") ? { title: text("source_title", 200) || "Where this came from", href } : null, | |
| 546 | }); | |
| 547 | return { text: done.message, outcome: done.ok ? "allowed" : "refused" }; | |
| 548 | } | |
| 549 | default: | |
| 550 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; | |
| 551 | } | |
| 552 | } | |
| 553 | ||
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 554 | /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */ |
| 555 | private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> { | |
| 556 | const actions = this.actions; | |
| 557 | const offered = this.definitions().some((tool) => tool.name === name); | |
| 558 | if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" }; | |
| 559 | const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" }); | |
| 560 | const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max); | |
| 561 | switch (name) { | |
| 562 | case "remember": { | |
| 563 | const fact = text("fact", 2000); | |
| 564 | if (!fact) return { text: "Say what to remember.", outcome: "refused" }; | |
| 565 | const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null; | |
| 566 | return said(await actions.remember(fact, scope)); | |
| 567 | } | |
| 568 | case "forget": | |
| 569 | return said(await actions.forget(text("id", 100))); | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 570 | case "draft_issue": { |
| 571 | if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld(); | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 572 | const repo = await this.audience.repo(input.repo); |
| 573 | if (!repo) return this.withheld(); | |
| 574 | const title = text("title", 200); | |
| 575 | const body = text("body", 20_000); | |
| 576 | if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" }; | |
| 577 | const labels = Array.isArray(input.labels) | |
| 578 | ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5) | |
| 579 | : []; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 580 | return said(await actions.draftIssue(repo, { title, body, labels })); |
| 581 | } | |
| 582 | case "comment": | |
| 583 | case "review_pull": { | |
| 584 | const asker = this.audience.asker; | |
| 585 | if (!asker || !this.audience.codeAllowed()) return this.withheld(); | |
| 586 | const repo = await this.audience.repo(input.repo); | |
| 587 | if (!repo) return this.withheld(); | |
| 588 | const number = Math.floor(Number(input.number)); | |
| 589 | if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" }; | |
| 590 | const body = text("body", 20_000); | |
| 591 | if (name === "comment") { | |
| 592 | if (!body) return { text: "Say what to comment.", outcome: "refused" }; | |
| 593 | return said(await actions.comment!(repo, asker, number, body)); | |
| 594 | } | |
| 595 | const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment"; | |
| 596 | if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" }; | |
| 597 | return said(await actions.review!(repo, asker, number, verdict, body)); | |
| Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked | 598 | } |
| 599 | case "start_session": { | |
| 600 | const title = text("title", 120); | |
| 601 | const goal = text("goal", 8000); | |
| 602 | if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" }; | |
| 603 | return said(await actions.startSession!(title, goal)); | |
| 604 | } | |
| 605 | case "post_update": { | |
| 606 | const note = text("text", 2000); | |
| 607 | if (!note) return { text: "Say what to post.", outcome: "refused" }; | |
| 608 | if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" }; | |
| 609 | this.updates++; | |
| 610 | return said(await actions.postUpdate!(note)); | |
| 611 | } | |
| 612 | case "use_subagent": { | |
| 613 | const helper = text("name", 60).toLowerCase(); | |
| 614 | const brief = text("brief", 8000); | |
| 615 | if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" }; | |
| 616 | return said(await actions.useSubagent!(helper, brief)); | |
| 617 | } | |
| 618 | case "bring_in": { | |
| 619 | const handle = text("handle", 60).replace(/^@/, "").toLowerCase(); | |
| 620 | const brief = text("brief", 8000); | |
| 621 | if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" }; | |
| 622 | if (this.context.notConsult.includes(handle)) return { text: `You can't bring in @${handle} here: they sent you this work, or it is you.`, outcome: "refused" }; | |
| 623 | return said(await actions.bringIn!(handle, brief)); | |
| 624 | } | |
| 625 | default: | |
| 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) | 626 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; |
| 627 | } | |
| 628 | } | |
| 629 | ||
| 630 | private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> { | |
| 631 | if (name === "list_repositories") { | |
| 632 | const repos = [...(await this.audience.repos()).values()]; | |
| 633 | if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" }; | |
| 634 | return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" }; | |
| 635 | } | |
| 636 | if (name === "search_code") { | |
| 637 | const query = String(input.query ?? "").trim(); | |
| 638 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; | |
| 639 | const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo); | |
| 640 | if (input.repo && !only) return this.withheld(); | |
| 641 | const allowed = await this.audience.repos(); | |
| 642 | // Whatever search returns, only hits in allowed repositories come through. | |
| 643 | const hits = (await this.ports.searchCode(viewer, query, only)).filter((hit) => allowed.has(hit.repo.toLowerCase()) && (!only || hit.repo.toLowerCase() === `${only.namespace}/${only.name}`.toLowerCase())); | |
| 644 | if (!hits.length) return { text: "No code found.", outcome: "allowed" }; | |
| 645 | return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" }; | |
| 646 | } | |
| 647 | if (name === "recent_activity") { | |
| 648 | const one = input.repo ? await this.audience.repo(input.repo) : null; | |
| 649 | if (input.repo && !one) return this.withheld(); | |
| 650 | const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20); | |
| 651 | if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" }; | |
| 652 | const pulls = await this.ports.recentPulls(repos, viewer); | |
| 653 | if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" }; | |
| 654 | return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" }; | |
| 655 | } | |
| 656 | // The rest name one repository; it must be on the allow-list. | |
| 657 | const repo = await this.audience.repo(input.repo); | |
| 658 | if (!repo) return this.withheld(); | |
| 659 | const full = `${repo.namespace}/${repo.name}`; | |
| 660 | switch (name) { | |
| 661 | case "read_file": { | |
| 662 | const path = String(input.path ?? "").trim().replace(/^\/+/, ""); | |
| 663 | if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" }; | |
| 664 | const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch; | |
| 665 | const file = await this.ports.readFile(repo, viewer, ref, path); | |
| 666 | if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" }; | |
| 667 | if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" }; | |
| 668 | return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" }; | |
| 669 | } | |
| 670 | case "list_issues": { | |
| 671 | const state = input.state === "closed" ? "closed" : "open"; | |
| 672 | const issues = await this.ports.listIssues(repo, viewer, state); | |
| 673 | if (!issues) return this.withheld(); | |
| 674 | if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" }; | |
| 675 | return { text: untrusted(`list_issues ${full}`, issues.slice(0, 30).map((i) => `#${i.number} [${i.state}] ${i.title}${i.labels.length ? ` (${i.labels.join(", ")})` : ""}`).join("\n")), outcome: "allowed" }; | |
| 676 | } | |
| 677 | case "get_issue": { | |
| 678 | const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer); | |
| 679 | if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" }; | |
| 680 | const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n"); | |
| 681 | return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" }; | |
| 682 | } | |
| 683 | case "get_pull": { | |
| 684 | const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer); | |
| 685 | if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" }; | |
| 686 | return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" }; | |
| 687 | } | |
| 688 | default: | |
| 689 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; | |
| 690 | } | |
| 691 | } | |
| 692 | } | |
| 693 | ||
| Docs: a workspace knowledge base people and agents write together | 694 | /** A page id from an id or a Docs link (`/acme/-/docs/general/runbook-pg_123`); null when there is none. */ |
| 695 | export function pageId(given: string): string | null { | |
| 696 | const text = given.trim(); | |
| 697 | if (!text) return null; | |
| 698 | const last = text.split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? text; | |
| 699 | const match = last.match(/(?:^|-)([a-z]{2,4}_[A-Za-z0-9]+)$/); | |
| 700 | return match ? match[1] : /^[A-Za-z0-9_-]{3,80}$/.test(last) ? last : null; | |
| 701 | } | |
| 702 | ||
| 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) | 703 | function messageLine(m: FoundMessage): string { |
| 704 | const where = m.channel ? `#${m.channel}` : "a direct message"; | |
| 705 | return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`; | |
| 706 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.