| 1 | /** |
| 2 | * What an agent can read while it replies: code, issues, pull requests, |
| 3 | * chat, the roster, and its colleagues (docs.g1t.sh/guides/agent-access/, |
| 4 | * "What an agent can and can't know"). |
| 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 | */ |
| 21 | import type { DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, User } from "@g1t/contracts"; |
| 22 | |
| 23 | import { FOLIO_KINDS, folioIdFrom, isFolioKind } from "../../../packages/contracts/src/folios.ts"; |
| 24 | import type { MakeFileFormat } from "../../../packages/contracts/src/skills.ts"; |
| 25 | import { type Audience, type RepoRef, WITHHELD } from "./audience.ts"; |
| 26 | import { makeFile, previewTable, readSheets, sizeLabel } from "./files.ts"; |
| 27 | |
| 28 | /** One tool, as the Messages API takes it. */ |
| 29 | export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> }; |
| 30 | |
| 31 | export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string }; |
| 32 | |
| 33 | /** What the tools reach outside this module. */ |
| 34 | export interface ToolPorts { |
| 35 | readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>; |
| 36 | searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>; |
| 37 | listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>; |
| 38 | getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>; |
| 39 | getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>; |
| 40 | recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>; |
| 41 | /** Chat's own audience rule applies; null when the search failed. */ |
| 42 | searchMessages(query: string): Promise<FoundMessage[] | null>; |
| 43 | /** Null when the audience may not read it (or it does not exist). */ |
| 44 | readThread(channelId: string, id: string): Promise<FoundMessage[] | null>; |
| 45 | roster(viewer: User | null): Promise<string>; |
| 46 | consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>; |
| 47 | /** |
| 48 | * The workspace's artifacts (Artifacts mode), as the docs service lets |
| 49 | * this agent use them for the person it acts for and everyone who will |
| 50 | * read the answer. Absent where there is no docs service. |
| 51 | */ |
| 52 | folios?: FoliosPorts; |
| 53 | } |
| 54 | |
| 55 | /** A space as an agent sees it, with what it may do there for the person it acts for. */ |
| 56 | export type FolioSpaceLine = { |
| 57 | id: string; |
| 58 | slug: string; |
| 59 | name: string; |
| 60 | description: string | null; |
| 61 | kind: string; |
| 62 | projects: string[]; |
| 63 | can: { read: boolean; suggest: boolean; edit: boolean }; |
| 64 | }; |
| 65 | |
| 66 | /** Where a new artifact goes: a space, its asker's Private, or Private shared with the conversation's people. */ |
| 67 | export type FolioWhere = { space_id: string } | "private" | { conversation: string[] }; |
| 68 | |
| 69 | /** A call's answer: the value, or the docs service's error code and sentence. */ |
| 70 | export type FolioDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string }; |
| 71 | |
| 72 | /** |
| 73 | * Artifacts, as an agent uses them. Every call names the person it acts |
| 74 | * for, and the reads also who reads the answer; the docs service checks |
| 75 | * both. |
| 76 | */ |
| 77 | export interface FoliosPorts { |
| 78 | /** Spaces everyone here can read; null when the docs service couldn't answer. */ |
| 79 | spaces(viewer: User, audience: FolioAudience): Promise<FolioSpaceLine[] | null>; |
| 80 | /** Passages closest in meaning to `query`; `spaces` (required reading) first. Null when the docs service couldn't answer. */ |
| 81 | recall(viewer: User, audience: FolioAudience, query: string, spaces: string[], kinds?: FolioKind[]): Promise<FolioPassage[] | null>; |
| 82 | /** Artifacts matching `query` (words and meaning), as lines with links. */ |
| 83 | search(viewer: User, audience: FolioAudience, input: { query: string; kind: FolioKind | null; space_id: string | null; project: string | null }): Promise<string | null>; |
| 84 | read(viewer: User, audience: FolioAudience, folioId: string): Promise<FolioDone<FolioAgentRead>>; |
| 85 | /** Artifacts possibly out of date since code they cite changed. */ |
| 86 | stale(viewer: User, audience: FolioAudience, repo: string | null): Promise<string | null>; |
| 87 | create( |
| 88 | viewer: User, |
| 89 | input: { kind: FolioKind; title: string; markdown: string | null; template_id: string | null; where: FolioWhere; parent_id: string | null; source: { title: string; href: string } | null }, |
| 90 | ): Promise<FolioDone<FolioRef>>; |
| 91 | edit(viewer: User, folioId: string, edit: FolioAgentEdit): Promise<FolioDone<FolioAgentEditResult>>; |
| 92 | /** `view` or `comment` for people already in this conversation. */ |
| 93 | share(viewer: User, audience: FolioAudience, folioId: string, userIds: string[], role: "view" | "comment"): Promise<FolioDone<null>>; |
| 94 | /** |
| 95 | * Keeps a file the agent made with a doc the asker can edit; `url` is |
| 96 | * where it is served (on the usercontent origin). Absent where files |
| 97 | * can't be kept. |
| 98 | */ |
| 99 | attach?(viewer: User, folioId: string, file: { name: string; content_type: string; bytes: Uint8Array }): Promise<FolioDone<{ url: string; name: string; bytes: number }>>; |
| 100 | /** Sends the asker a link directly, as a message from the agent in their DM with it; false when it couldn't. */ |
| 101 | sendLink(asker: User, link: { title: string; path: string }, note: string): Promise<boolean>; |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * What an agent may do, beyond reading: remember, file an issue for the |
| 106 | * person who asked, and start or shape work. Each is checked here before it |
| 107 | * runs (the audience, the asker, the hop limit) and again by the service |
| 108 | * that does it. |
| 109 | */ |
| 110 | export interface ActionPorts { |
| 111 | /** |
| 112 | * `onlyForAsker`: this turn read an artifact the whole workspace can't |
| 113 | * read, so the fact is kept for the person who asked alone. |
| 114 | */ |
| 115 | remember(body: string, scope: "workspace" | "channel" | "person" | null, onlyForAsker?: boolean): Promise<{ ok: boolean; message: string }>; |
| 116 | forget(id: string): Promise<{ ok: boolean; message: string }>; |
| 117 | /** |
| 118 | * Posts a draft issue as a card in the conversation, with File issue and |
| 119 | * Discard: whoever presses File files it as themselves, if they can read |
| 120 | * the repository. Nothing is filed by the agent. |
| 121 | */ |
| 122 | draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>; |
| 123 | /** |
| 124 | * Comments on an issue or pull request, or reviews a pull request, as the |
| 125 | * agent on behalf of the person who asked. Reviews are advisory: they |
| 126 | * never count toward required approvals. |
| 127 | */ |
| 128 | comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>; |
| 129 | review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>; |
| 130 | /** From chat: spins off a session for real work. */ |
| 131 | startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>; |
| 132 | /** In a session: a short progress note in its thread. */ |
| 133 | postUpdate?(text: string): Promise<{ ok: boolean; message: string }>; |
| 134 | /** In a session: one of the agent's own subagents takes part of the work. */ |
| 135 | useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>; |
| 136 | /** In a session: a colleague works on part of it, paid from this session's budget. */ |
| 137 | bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>; |
| 138 | /** From chat: a colleague takes the work on for the person who asked, here or in a group message. */ |
| 139 | handOff?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>; |
| 140 | } |
| 141 | |
| 142 | /** The most hand-offs one reply makes. */ |
| 143 | export const MAX_HAND_OFFS = 2; |
| 144 | |
| 145 | /** The most tool calls one reply makes. */ |
| 146 | export const MAX_TOOL_CALLS = 8; |
| 147 | /** The most tool calls one step of a session makes. */ |
| 148 | export const MAX_SESSION_TOOL_CALLS = 24; |
| 149 | /** The most of a file or result an answer is given, in characters. */ |
| 150 | const MAX_RESULT = 20_000; |
| 151 | |
| 152 | export type ToolCall = { |
| 153 | tool: string; |
| 154 | /** Its arguments, with long text cut, as recorded. */ |
| 155 | args: string; |
| 156 | outcome: "allowed" | "withheld" | "refused" | "error"; |
| 157 | bytes: number; |
| 158 | }; |
| 159 | |
| 160 | export type ToolResult = { text: string; outcome: ToolCall["outcome"] }; |
| 161 | |
| 162 | /** |
| 163 | * Text a tool read, marked as data. Anything in it that looks like the |
| 164 | * closing mark is defused, so content can't end the block early. |
| 165 | */ |
| 166 | export function untrusted(source: string, content: string): string { |
| 167 | const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "<")); |
| 168 | const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content; |
| 169 | return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`; |
| 170 | } |
| 171 | |
| 172 | /** Arguments as recorded: every string cut to 120 characters. */ |
| 173 | export function redact(args: unknown): string { |
| 174 | const cut = (value: unknown): unknown => { |
| 175 | if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value; |
| 176 | if (Array.isArray(value)) return value.slice(0, 10).map(cut); |
| 177 | if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)])); |
| 178 | return value; |
| 179 | }; |
| 180 | return JSON.stringify(cut(args ?? {})).slice(0, 1000); |
| 181 | } |
| 182 | |
| 183 | const CODE_TOOLS: ToolDef[] = [ |
| 184 | { |
| 185 | name: "list_repositories", |
| 186 | description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.", |
| 187 | input_schema: { type: "object", properties: {} }, |
| 188 | }, |
| 189 | { |
| 190 | name: "search_code", |
| 191 | description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).", |
| 192 | input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] }, |
| 193 | }, |
| 194 | { |
| 195 | name: "read_file", |
| 196 | description: "Read a file from a repository, at its default branch or a ref.", |
| 197 | input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] }, |
| 198 | }, |
| 199 | { |
| 200 | name: "list_issues", |
| 201 | description: "A repository's newest issues, open by default.", |
| 202 | input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] }, |
| 203 | }, |
| 204 | { |
| 205 | name: "get_issue", |
| 206 | description: "One issue with its comments.", |
| 207 | input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] }, |
| 208 | }, |
| 209 | { |
| 210 | name: "get_pull", |
| 211 | description: "One pull request: what it changes, its status and checks.", |
| 212 | input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] }, |
| 213 | }, |
| 214 | { |
| 215 | name: "recent_activity", |
| 216 | description: "Recently merged and open pull requests, in one repository or across those you can read.", |
| 217 | input_schema: { type: "object", properties: { repo: { type: "string" } } }, |
| 218 | }, |
| 219 | ]; |
| 220 | |
| 221 | const CHAT_TOOLS: ToolDef[] = [ |
| 222 | { |
| 223 | name: "search_messages", |
| 224 | description: "Search chat messages this conversation's people can all read.", |
| 225 | input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, |
| 226 | }, |
| 227 | { |
| 228 | name: "read_thread", |
| 229 | description: "Read a chat thread by its channel id and a message id in it (from search_messages).", |
| 230 | input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] }, |
| 231 | }, |
| 232 | { |
| 233 | name: "workspace_roster", |
| 234 | description: "The workspace's people and agents: names, teams, titles and roles.", |
| 235 | input_schema: { type: "object", properties: {} }, |
| 236 | }, |
| 237 | ]; |
| 238 | |
| 239 | const ASK_COLLEAGUE: ToolDef = { |
| 240 | name: "ask_colleague", |
| 241 | description: |
| 242 | "Ask a colleague agent a quick question, privately: their answer comes back to you alone, they do no work in the conversation, and the work stays yours. Use it when their role knows something yours doesn't. To give them the work itself, use hand_off.", |
| 243 | input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] }, |
| 244 | }; |
| 245 | |
| 246 | const HAND_OFF: ToolDef = { |
| 247 | name: "hand_off", |
| 248 | description: |
| 249 | "Hand work to a colleague agent, for the person who asked: they take it on and answer that person themselves, with that person's access. If they are a member of this conversation (a channel or group message), your brief is posted here; otherwise a group message opens (or is reused) with the person who asked, you and them, your brief goes there, and a card here links to it. It is the only way to get a colleague working: an @mention in your message wakes nobody. Give their handle and a complete brief written to them: what is wanted, why, what done looks like, and what they need from this conversation. For a quick question you answer with, use ask_colleague instead.", |
| 250 | input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] }, |
| 251 | }; |
| 252 | |
| 253 | const REMEMBER: ToolDef = { |
| 254 | name: "remember", |
| 255 | description: |
| 256 | "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.", |
| 257 | input_schema: { |
| 258 | type: "object", |
| 259 | properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } }, |
| 260 | required: ["fact"], |
| 261 | }, |
| 262 | }; |
| 263 | |
| 264 | const FORGET: ToolDef = { |
| 265 | name: "forget", |
| 266 | description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.", |
| 267 | input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, |
| 268 | }; |
| 269 | |
| 270 | const DRAFT_ISSUE: ToolDef = { |
| 271 | name: "draft_issue", |
| 272 | description: |
| 273 | "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.", |
| 274 | input_schema: { |
| 275 | type: "object", |
| 276 | properties: { |
| 277 | repo: { type: "string" }, |
| 278 | title: { type: "string" }, |
| 279 | body: { type: "string" }, |
| 280 | labels: { type: "array", items: { type: "string" } }, |
| 281 | }, |
| 282 | required: ["repo", "title", "body"], |
| 283 | }, |
| 284 | }; |
| 285 | |
| 286 | const COMMENT: ToolDef = { |
| 287 | name: "comment", |
| 288 | description: |
| 289 | "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.", |
| 290 | input_schema: { |
| 291 | type: "object", |
| 292 | properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } }, |
| 293 | required: ["repo", "number", "body"], |
| 294 | }, |
| 295 | }; |
| 296 | |
| 297 | const REVIEW_PULL: ToolDef = { |
| 298 | name: "review_pull", |
| 299 | description: |
| 300 | "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.", |
| 301 | input_schema: { |
| 302 | type: "object", |
| 303 | properties: { |
| 304 | repo: { type: "string" }, |
| 305 | number: { type: "integer" }, |
| 306 | verdict: { type: "string", enum: ["comment", "approve", "request_changes"] }, |
| 307 | body: { type: "string" }, |
| 308 | }, |
| 309 | required: ["repo", "number", "verdict", "body"], |
| 310 | }, |
| 311 | }; |
| 312 | |
| 313 | const START_SESSION: ToolDef = { |
| 314 | name: "start_session", |
| 315 | description: |
| 316 | "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.", |
| 317 | input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] }, |
| 318 | }; |
| 319 | |
| 320 | const POST_UPDATE: ToolDef = { |
| 321 | name: "post_update", |
| 322 | 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.", |
| 323 | input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, |
| 324 | }; |
| 325 | |
| 326 | const USE_SUBAGENT: ToolDef = { |
| 327 | name: "use_subagent", |
| 328 | description: |
| 329 | "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.", |
| 330 | input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] }, |
| 331 | }; |
| 332 | |
| 333 | const BRING_IN: ToolDef = { |
| 334 | name: "bring_in", |
| 335 | description: |
| 336 | "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.", |
| 337 | input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] }, |
| 338 | }; |
| 339 | |
| 340 | /** What every artifact tool's description starts from, so the model never mixes them up with workflow run artifacts. */ |
| 341 | const ARTIFACTS = |
| 342 | "Artifacts are the workspace's own documents, made and shared in its Artifacts section: docs now, and later slides, designs and dashboards. They are not a workflow run's build artifacts."; |
| 343 | |
| 344 | const FOLIO_TOOLS: ToolDef[] = [ |
| 345 | { |
| 346 | name: "search_artifacts", |
| 347 | description: `${ARTIFACTS} Search the artifacts everyone in this conversation can read (specs, runbooks, policies, onboarding, decisions), by words and meaning, plus projects' docs. Optionally only one kind, one space (its name or id from list_spaces) or one project (\`workspace/name\`). Each result has its link and id. Look here first for how things work and what was decided.`, |
| 348 | input_schema: { |
| 349 | type: "object", |
| 350 | properties: { |
| 351 | query: { type: "string" }, |
| 352 | kind: { type: "string", enum: [...FOLIO_KINDS] }, |
| 353 | space: { type: "string", description: "A space's name or id." }, |
| 354 | project: { type: "string", description: "A repository, `workspace/name`." }, |
| 355 | }, |
| 356 | required: ["query"], |
| 357 | }, |
| 358 | }, |
| 359 | { |
| 360 | name: "read_artifact", |
| 361 | description: `${ARTIFACTS} Read one artifact by its id (fol_…) or its link (…/-/artifacts/<name>-fol_…). A doc comes back as Markdown with the ids of its top-level blocks (for edit_artifact), and says what you may do with it.`, |
| 362 | input_schema: { type: "object", properties: { id: { type: "string", description: "Its id or link." } }, required: ["id"] }, |
| 363 | }, |
| 364 | { |
| 365 | name: "list_spaces", |
| 366 | description: `${ARTIFACTS} The spaces whose artifacts everyone in this conversation can read, with what you may do in each (read, suggest, edit).`, |
| 367 | input_schema: { type: "object", properties: {} }, |
| 368 | }, |
| 369 | { |
| 370 | name: "stale_artifacts", |
| 371 | description: `${ARTIFACTS} Artifacts possibly out of date because code they cite changed. Optionally only for one repository (\`workspace/name\`). Start here when keeping the docs current: read each, then bring it up to date with edit_artifact and marks_current.`, |
| 372 | input_schema: { type: "object", properties: { repo: { type: "string" } } }, |
| 373 | }, |
| 374 | ]; |
| 375 | |
| 376 | const FOLIO_WRITE_TOOLS: ToolDef[] = [ |
| 377 | { |
| 378 | name: "create_artifact", |
| 379 | description: `${ARTIFACTS} Make a new artifact ("write this up"). Only kind "doc" can be made for now. Give a title and its content as Markdown (or a template id). where: a space (its name or id from list_spaces) as { "space": "..." }, "private" for the person who asked alone, or "conversation" for them plus view access for this conversation's people. Left out: shared with this conversation in a direct message or private channel, the General space in a public channel. It belongs to the person who asked, and you can keep editing it. When it comes from a conversation, set source to that thread's link.`, |
| 380 | input_schema: { |
| 381 | type: "object", |
| 382 | properties: { |
| 383 | kind: { type: "string", enum: [...FOLIO_KINDS] }, |
| 384 | title: { type: "string" }, |
| 385 | content: { type: "string", description: "Markdown." }, |
| 386 | template: { type: "string", description: "A template id, instead of content." }, |
| 387 | where: { |
| 388 | description: '{ "space": "<name or id>" }, "private" or "conversation".', |
| 389 | anyOf: [ |
| 390 | { type: "string", enum: ["private", "conversation"] }, |
| 391 | { type: "object", properties: { space: { type: "string" } }, required: ["space"] }, |
| 392 | ], |
| 393 | }, |
| 394 | parent: { type: "string", description: "A doc to put it under: its id or link." }, |
| 395 | source: { type: "string", description: "The link of the thread it was written up from." }, |
| 396 | }, |
| 397 | required: ["kind", "title"], |
| 398 | }, |
| 399 | }, |
| 400 | { |
| 401 | name: "edit_artifact", |
| 402 | description: `${ARTIFACTS} Change a doc: replace a section (by its heading), a range of top-level blocks (ids from read_artifact), the whole doc, or add to the end. Where you may edit, it applies at once and shows in its history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read it first. Write Markdown. Only docs can be changed this way for now.`, |
| 403 | input_schema: { |
| 404 | type: "object", |
| 405 | properties: { |
| 406 | id: { type: "string", description: "Its id or link." }, |
| 407 | target: { type: "string", enum: ["append", "section", "blocks", "document"] }, |
| 408 | heading: { type: "string", description: "For target section: the heading's text." }, |
| 409 | from_block: { type: "string" }, |
| 410 | to_block: { type: "string" }, |
| 411 | markdown: { type: "string" }, |
| 412 | note: { type: "string", description: "Why, in a line, for the history or the suggestion." }, |
| 413 | marks_current: { type: "boolean", description: "This edit brings a doc marked possibly out of date up to date: it clears the mark when it applies or is accepted." }, |
| 414 | suggest_only: { type: "boolean", description: "Suggest even where you could edit." }, |
| 415 | }, |
| 416 | required: ["id", "target", "markdown"], |
| 417 | }, |
| 418 | }, |
| 419 | { |
| 420 | name: "share_artifact", |
| 421 | description: `${ARTIFACTS} Let people in this conversation view or comment on an artifact, when the person who asked has full access to it. Only in a direct message or a private channel, and only with people already in it. You can't give edit or full access, or change who else can open it: for that, ask the person to use Share.`, |
| 422 | input_schema: { |
| 423 | type: "object", |
| 424 | properties: { |
| 425 | id: { type: "string", description: "Its id or link." }, |
| 426 | people: { type: "array", items: { type: "string" }, description: "Usernames of people in this conversation." }, |
| 427 | role: { type: "string", enum: ["view", "comment"] }, |
| 428 | }, |
| 429 | required: ["id", "people", "role"], |
| 430 | }, |
| 431 | }, |
| 432 | ]; |
| 433 | |
| 434 | /** |
| 435 | * Files people asked for (the Documents, Data and Files and media skills): |
| 436 | * written here, kept with a doc the person who asked owns or can edit, and |
| 437 | * served from the usercontent origin like an upload. |
| 438 | */ |
| 439 | const MAKE_FILE: ToolDef = { |
| 440 | name: "make_file", |
| 441 | description: |
| 442 | 'Make a file someone asked for and keep it with a doc in Artifacts: format "pdf" or "docx" (a Word document) from Markdown in content (headings, paragraphs, bold, italic, code, links, lists, quotes, code blocks, tables); "xlsx" (a spreadsheet, one or more sheets) or "csv" (one sheet) from sheets, each { name, rows }, the first row its header and numbers as numbers; or "md". Give a title (the file is named after it). Without artifact, a new doc is made holding the content (or a preview of the rows) with the file attached; with artifact (a doc\'s id or link you can edit), the file is attached to that doc. where works as for create_artifact. It returns the file\'s link: give it to them.', |
| 443 | input_schema: { |
| 444 | type: "object", |
| 445 | properties: { |
| 446 | format: { type: "string", enum: ["pdf", "docx", "xlsx", "csv", "md"] }, |
| 447 | title: { type: "string" }, |
| 448 | content: { type: "string", description: "Markdown, for pdf, docx and md." }, |
| 449 | sheets: { |
| 450 | type: "array", |
| 451 | description: "For xlsx and csv.", |
| 452 | items: { |
| 453 | type: "object", |
| 454 | properties: { name: { type: "string" }, rows: { type: "array", items: { type: "array", items: {} } } }, |
| 455 | required: ["rows"], |
| 456 | }, |
| 457 | }, |
| 458 | artifact: { type: "string", description: "A doc to attach it to: its id or link. Left out: a new doc." }, |
| 459 | where: { |
| 460 | description: '{ "space": "<name or id>" }, "private" or "conversation", for a new doc.', |
| 461 | anyOf: [{ type: "string" }, { type: "object", properties: { space: { type: "string" } }, required: ["space"] }], |
| 462 | }, |
| 463 | }, |
| 464 | required: ["format", "title"], |
| 465 | }, |
| 466 | }; |
| 467 | |
| 468 | const FOLIO_NAMES = new Set([...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE].map((tool) => tool.name)); |
| 469 | |
| 470 | /** Every tool an agent may be offered, by name: what skills may name (@g1t/contracts skills.ts). */ |
| 471 | export const TOOL_NAMES: ReadonlySet<string> = new Set( |
| 472 | [...CODE_TOOLS, ...CHAT_TOOLS, ...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE, ASK_COLLEAGUE, HAND_OFF, REMEMBER, FORGET, DRAFT_ISSUE, COMMENT, REVIEW_PULL, START_SESSION, POST_UPDATE, USE_SUBAGENT, BRING_IN].map((tool) => tool.name), |
| 473 | ); |
| 474 | |
| 475 | const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name)); |
| 476 | |
| 477 | export type ToolContext = { |
| 478 | /** The agent replying. */ |
| 479 | agentId: string; |
| 480 | /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */ |
| 481 | notConsult: string[]; |
| 482 | /** Hops so far: a consult is one more, and none is offered at the limit. */ |
| 483 | hops: number; |
| 484 | maxHops: number; |
| 485 | /** Whether this is a session's step (more calls, session tools) or a reply. */ |
| 486 | session?: boolean; |
| 487 | /** Told of every call as it is made, for a session's transcript. */ |
| 488 | onCall?: (call: ToolCall) => void; |
| 489 | }; |
| 490 | |
| 491 | export class ToolBox { |
| 492 | /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */ |
| 493 | readonly calls: ToolCall[]; |
| 494 | private readonly audience: Audience; |
| 495 | private readonly ports: ToolPorts; |
| 496 | private readonly context: ToolContext; |
| 497 | |
| 498 | private readonly actions: ActionPorts | null; |
| 499 | /** Updates posted in this step. */ |
| 500 | private updates = 0; |
| 501 | /** Hand-offs made in this reply. */ |
| 502 | private handOffs = 0; |
| 503 | /** Artifacts read or made in this turn that not everyone here can read: never named here. */ |
| 504 | private readonly notHere = new Set<string>(); |
| 505 | /** |
| 506 | * Whether this turn read an artifact the whole workspace can't: then what |
| 507 | * it remembers is kept for the person who asked alone. |
| 508 | */ |
| 509 | private privateRead = false; |
| 510 | |
| 511 | constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) { |
| 512 | this.audience = audience; |
| 513 | this.ports = ports; |
| 514 | this.context = context; |
| 515 | this.calls = calls; |
| 516 | this.actions = actions; |
| 517 | } |
| 518 | |
| 519 | /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */ |
| 520 | forColleague(ports: ToolPorts, context: ToolContext): ToolBox { |
| 521 | return new ToolBox(this.audience, ports, context, this.calls); |
| 522 | } |
| 523 | |
| 524 | /** The most calls this box makes. */ |
| 525 | get maxCalls(): number { |
| 526 | return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS; |
| 527 | } |
| 528 | |
| 529 | /** Whether the person who asked can be acted for: resolved, and able to read code here. */ |
| 530 | private canFile(): boolean { |
| 531 | return !!this.actions && !!this.audience.asker && this.audience.codeAllowed(); |
| 532 | } |
| 533 | |
| 534 | /** |
| 535 | * The tools offered: no code tools for an audience that can't read code, |
| 536 | * no consults or hand-offs at the hop limit, session tools only in a |
| 537 | * session, and a spin-off only from chat. |
| 538 | */ |
| 539 | definitions(): ToolDef[] { |
| 540 | const roomForHop = this.context.hops + 1 <= this.context.maxHops; |
| 541 | const actions = this.actions; |
| 542 | return [ |
| 543 | ...(this.audience.codeAllowed() ? CODE_TOOLS : []), |
| 544 | ...CHAT_TOOLS, |
| 545 | // Artifacts are for everyone, Code or not: the docs service decides what this person and audience can read. |
| 546 | ...(this.ports.folios && this.audience.asker ? FOLIO_TOOLS : []), |
| 547 | ...(this.ports.folios && this.audience.asker && actions ? FOLIO_WRITE_TOOLS : []), |
| 548 | ...(this.ports.folios?.attach && this.audience.asker && actions ? [MAKE_FILE] : []), |
| 549 | ...(roomForHop ? [ASK_COLLEAGUE] : []), |
| 550 | ...(actions ? [REMEMBER, FORGET] : []), |
| 551 | ...(this.canFile() ? [DRAFT_ISSUE] : []), |
| 552 | ...(this.canFile() && actions?.comment ? [COMMENT] : []), |
| 553 | ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []), |
| 554 | ...(actions?.startSession && !this.context.session ? [START_SESSION] : []), |
| 555 | ...(actions?.handOff && !this.context.session && roomForHop ? [HAND_OFF] : []), |
| 556 | ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []), |
| 557 | ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []), |
| 558 | ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []), |
| 559 | ]; |
| 560 | } |
| 561 | |
| 562 | /** Whether another call may be made. */ |
| 563 | get spent(): boolean { |
| 564 | return this.calls.length >= this.maxCalls; |
| 565 | } |
| 566 | |
| 567 | async run(name: string, input: Record<string, unknown>): Promise<ToolResult> { |
| 568 | const result = await this.attempt(name, input); |
| 569 | const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length }; |
| 570 | this.calls.push(call); |
| 571 | this.context.onCall?.(call); |
| 572 | return result; |
| 573 | } |
| 574 | |
| 575 | private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> { |
| 576 | let result: ToolResult; |
| 577 | const what = this.context.session ? "step" : "reply"; |
| 578 | if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" }; |
| 579 | else { |
| 580 | try { |
| 581 | result = await this.dispatch(name, input ?? {}); |
| 582 | } catch (error) { |
| 583 | console.error("agents: a tool failed", name, String(error)); |
| 584 | result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" }; |
| 585 | } |
| 586 | } |
| 587 | return result; |
| 588 | } |
| 589 | |
| 590 | private withheld(): ToolResult { |
| 591 | return { text: WITHHELD, outcome: "withheld" }; |
| 592 | } |
| 593 | |
| 594 | private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> { |
| 595 | const asker = this.audience.asker; |
| 596 | if (FOLIO_NAMES.has(name)) { |
| 597 | if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" }; |
| 598 | return this.folios(name, input, asker, this.ports.folios); |
| 599 | } |
| 600 | if (CODE_NAMES.has(name)) { |
| 601 | // Not offered, and refused if asked for anyway: the check is here, not in the prompt. |
| 602 | if (!this.audience.codeAllowed() || !asker) return this.withheld(); |
| 603 | return this.code(name, input, asker); |
| 604 | } |
| 605 | switch (name) { |
| 606 | case "search_messages": { |
| 607 | const query = String(input.query ?? "").trim(); |
| 608 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; |
| 609 | const found = await this.ports.searchMessages(query); |
| 610 | if (found === null) return { text: "Search didn't work just now.", outcome: "error" }; |
| 611 | if (!found.length) return { text: "No messages found.", outcome: "allowed" }; |
| 612 | return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" }; |
| 613 | } |
| 614 | case "read_thread": { |
| 615 | const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? "")); |
| 616 | if (!thread || !thread.length) return this.withheld(); |
| 617 | return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" }; |
| 618 | } |
| 619 | case "workspace_roster": |
| 620 | return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" }; |
| 621 | case "ask_colleague": { |
| 622 | const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase(); |
| 623 | const question = String(input.question ?? "").trim(); |
| 624 | if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" }; |
| 625 | 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" }; |
| 626 | if (this.context.notConsult.includes(handle)) { |
| 627 | return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" }; |
| 628 | } |
| 629 | const answer = await this.ports.consult(handle, question.slice(0, 2000)); |
| 630 | if (!answer.ok) return { text: answer.message, outcome: "refused" }; |
| 631 | return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" }; |
| 632 | } |
| 633 | default: |
| 634 | return this.act(name, input); |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /** |
| 639 | * What the workspace's artifacts say about `query`, for this person and |
| 640 | * this audience, before the agent answers: no tool call, nothing counted |
| 641 | * against its tools. Empty when there is no docs service or nothing |
| 642 | * relevant. |
| 643 | */ |
| 644 | async recall(query: string | null, spaces: string[]): Promise<FolioPassage[]> { |
| 645 | const folios = this.ports.folios; |
| 646 | const asker = this.audience.asker; |
| 647 | if (!folios || !asker || !query) return []; |
| 648 | try { |
| 649 | return (await folios.recall(asker, this.folioAudience(), query, spaces)) ?? []; |
| 650 | } catch (error) { |
| 651 | console.error("agents: artifacts recall failed", String(error)); |
| 652 | return []; |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | /** Who reads what an agent says here, as the docs service takes it. */ |
| 657 | private folioAudience(): FolioAudience { |
| 658 | return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) }; |
| 659 | } |
| 660 | |
| 661 | /** Whether anyone besides the person who asked reads what is said here. */ |
| 662 | private othersHere(asker: User): boolean { |
| 663 | return this.audience.shared || this.audience.members.some((m) => m.id !== asker.id); |
| 664 | } |
| 665 | |
| 666 | private async folios(name: string, input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> { |
| 667 | const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max); |
| 668 | const audience = this.folioAudience(); |
| 669 | switch (name) { |
| 670 | case "list_spaces": { |
| 671 | const spaces = await folios.spaces(asker, audience); |
| 672 | if (spaces === null) return { text: "Spaces couldn't be listed just now.", outcome: "error" }; |
| 673 | if (!spaces.length) return { text: "There are no spaces everyone here can read.", outcome: "allowed" }; |
| 674 | return { text: untrusted("list_spaces", spaces.map(spaceLine).join("\n")), outcome: "allowed" }; |
| 675 | } |
| 676 | case "search_artifacts": { |
| 677 | const query = text("query", 200); |
| 678 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; |
| 679 | const kind = text("kind", 20) || null; |
| 680 | if (kind && !isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}: it is doc, slides, design or dashboard.`, outcome: "refused" }; |
| 681 | let spaceId: string | null = null; |
| 682 | if (text("space", 200)) { |
| 683 | const space = findSpace((await folios.spaces(asker, audience)) ?? [], text("space", 200)); |
| 684 | if (!space) return this.withheld(); |
| 685 | spaceId = space.id; |
| 686 | } |
| 687 | const found = await folios.search(asker, audience, { query, kind: kind as FolioKind | null, space_id: spaceId, project: text("project", 200).toLowerCase() || null }); |
| 688 | if (found === null) return { text: "Search didn't work just now.", outcome: "error" }; |
| 689 | return { text: untrusted(`search_artifacts "${query}"`, found), outcome: "allowed" }; |
| 690 | } |
| 691 | case "stale_artifacts": { |
| 692 | const found = await folios.stale(asker, audience, text("repo", 200).toLowerCase() || null); |
| 693 | if (found === null) return { text: "Out-of-date artifacts couldn't be listed just now.", outcome: "error" }; |
| 694 | return { text: untrusted("stale_artifacts", found), outcome: "allowed" }; |
| 695 | } |
| 696 | case "read_artifact": { |
| 697 | const id = folioRef(text("id", 500)); |
| 698 | if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" }; |
| 699 | const found = await folios.read(asker, audience, id); |
| 700 | if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" }; |
| 701 | const read = found.value; |
| 702 | if (!this.audience.shared || !read.audience_can_read) this.privateRead = true; |
| 703 | if (!read.audience_can_read) return this.notForEveryone(asker, read.folio, folios, "found"); |
| 704 | return { text: untrusted(`read_artifact ${id}`, folioReadText(read)), outcome: "allowed" }; |
| 705 | } |
| 706 | case "create_artifact": { |
| 707 | const kind = text("kind", 20) || "doc"; |
| 708 | if (!isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}.`, outcome: "refused" }; |
| 709 | if (kind !== "doc") return { text: NOT_YET, outcome: "refused" }; |
| 710 | const title = text("title", 200); |
| 711 | const template = text("template", 100) || null; |
| 712 | const markdown = String(input.content ?? "").slice(0, 100_000); |
| 713 | if (!title) return { text: "An artifact needs a title.", outcome: "refused" }; |
| 714 | if (!markdown.trim() && !template) return { text: "Give its content as Markdown, or a template.", outcome: "refused" }; |
| 715 | const parentGiven = text("parent", 500); |
| 716 | const parent = parentGiven ? folioRef(parentGiven) : null; |
| 717 | if (parentGiven && !parent) return { text: "Give the parent doc's id or link.", outcome: "refused" }; |
| 718 | const place = await this.whereFor(input.where, asker, folios); |
| 719 | if (!place.ok) return { text: place.message, outcome: "refused" }; |
| 720 | const make = (where: FolioWhere) => |
| 721 | folios.create(asker, { kind, title, markdown: template ? null : markdown, template_id: template, where, parent_id: parent, source: sourceLink(text("source", 2000)) }); |
| 722 | let made = await make(place.where); |
| 723 | // The General space by default, unless the person who asked can't add there: then their Private. |
| 724 | if (!made.ok && place.fallback && made.code === "forbidden") made = await make("private"); |
| 725 | if (!made.ok) return { text: `It couldn't be made: ${made.message}`, outcome: "refused" }; |
| 726 | const ref = made.value; |
| 727 | if (this.othersHere(asker)) { |
| 728 | const check = await folios.read(asker, audience, ref.id).catch(() => null); |
| 729 | if (!check?.ok || !check.value.audience_can_read) return this.notForEveryone(asker, ref, folios, "made"); |
| 730 | } |
| 731 | return { text: `Wrote ${ref.title} (${ref.path}, id ${ref.id}). Link it.`, outcome: "allowed" }; |
| 732 | } |
| 733 | case "edit_artifact": { |
| 734 | const id = folioRef(text("id", 500)); |
| 735 | const markdown = String(input.markdown ?? "").slice(0, 100_000); |
| 736 | if (!id || !markdown.trim()) return { text: "Give the artifact's id or link, and the Markdown.", outcome: "refused" }; |
| 737 | const kind = text("target", 20); |
| 738 | const target: DocEditTarget | null = |
| 739 | kind === "append" |
| 740 | ? { kind: "append" } |
| 741 | : kind === "document" |
| 742 | ? { kind: "document" } |
| 743 | : kind === "section" && text("heading", 300) |
| 744 | ? { kind: "section", heading: text("heading", 300) } |
| 745 | : kind === "blocks" && text("from_block", 100) && text("to_block", 100) |
| 746 | ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) } |
| 747 | : null; |
| 748 | if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" }; |
| 749 | const edit: FolioAgentEdit = { kind: "doc", target, markdown, note: text("note", 300) || null, suggest_only: input.suggest_only === true, marks_current: input.marks_current === true }; |
| 750 | const done = await folios.edit(asker, id, edit); |
| 751 | if (!done.ok) return { text: `That didn't work: ${done.message}`, outcome: "refused" }; |
| 752 | return { text: editMessage(done.value, !this.notHere.has(done.value.folio.id)), outcome: "allowed" }; |
| 753 | } |
| 754 | case "share_artifact": { |
| 755 | if (this.audience.shared) return { text: "You can share only in a direct message or a private channel. Ask the person to use Share on the artifact instead.", outcome: "refused" }; |
| 756 | const id = folioRef(text("id", 500)); |
| 757 | if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" }; |
| 758 | const role = input.role === "view" || input.role === "comment" ? input.role : null; |
| 759 | if (!role) return { text: "You can share to view or comment only. For more, ask the person to use Share.", outcome: "refused" }; |
| 760 | const named = (Array.isArray(input.people) ? input.people : []).filter((p): p is string => typeof p === "string").map((p) => p.trim().replace(/^@/, "").toLowerCase()).filter(Boolean).slice(0, 50); |
| 761 | const people = named.map((n) => this.audience.members.find((m) => m.username.toLowerCase() === n || m.id === n) ?? n); |
| 762 | const outside = people.filter((p): p is string => typeof p === "string"); |
| 763 | if (outside.length) return { text: `${outside.map((n) => `@${n}`).join(", ")} ${outside.length === 1 ? "isn't" : "aren't"} in this conversation: you can share only with people in it.`, outcome: "refused" }; |
| 764 | const users = [...new Map((people as User[]).filter((u) => u.id !== asker.id).map((u) => [u.id, u])).values()]; |
| 765 | if (!users.length) return { text: "Name who to share it with: people in this conversation besides the person who asked.", outcome: "refused" }; |
| 766 | const done = await folios.share(asker, audience, id, users.map((u) => u.id), role); |
| 767 | if (!done.ok) return { text: `It couldn't be shared: ${done.message}`, outcome: "refused" }; |
| 768 | return { text: `Shared with ${users.map((u) => `@${u.username}`).join(", ")}: they can ${role} it.`, outcome: "allowed" }; |
| 769 | } |
| 770 | case "make_file": |
| 771 | return this.makeFile(input, asker, folios); |
| 772 | default: |
| 773 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; |
| 774 | } |
| 775 | } |
| 776 | |
| 777 | /** |
| 778 | * A file someone asked for (files.ts), kept with a doc: the one named, |
| 779 | * which the asker must be able to edit, or a new one holding the content. |
| 780 | * Where not everyone here can read that doc, the link goes to the asker |
| 781 | * directly, as for any artifact. |
| 782 | */ |
| 783 | private async makeFile(input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> { |
| 784 | if (!folios.attach) return { text: "There is no tool called make_file here.", outcome: "refused" }; |
| 785 | const title = String(input.title ?? "").trim().slice(0, 200); |
| 786 | const made = makeFile({ format: input.format, title, content: input.content, sheets: input.sheets }); |
| 787 | if (!made.ok) return { text: made.message, outcome: "refused" }; |
| 788 | const file = made.file; |
| 789 | const audience = this.folioAudience(); |
| 790 | const given = String(input.artifact ?? "").trim().slice(0, 500); |
| 791 | let ref: FolioRef; |
| 792 | let hidden = false; |
| 793 | if (given) { |
| 794 | const id = folioRef(given); |
| 795 | if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" }; |
| 796 | const found = await folios.read(asker, audience, id); |
| 797 | if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" }; |
| 798 | if (!found.value.can.edit) return { text: "You can't edit that doc for them, so nothing can be attached to it. Leave artifact out to make a new doc.", outcome: "refused" }; |
| 799 | ref = found.value.folio; |
| 800 | if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true; |
| 801 | hidden = !found.value.audience_can_read; |
| 802 | } else { |
| 803 | const place = await this.whereFor(input.where, asker, folios); |
| 804 | if (!place.ok) return { text: place.message, outcome: "refused" }; |
| 805 | const body = docBody(file.format, title, input); |
| 806 | const make = (where: FolioWhere) => folios.create(asker, { kind: "doc", title, markdown: body, template_id: null, where, parent_id: null, source: null }); |
| 807 | let created = await make(place.where); |
| 808 | if (!created.ok && place.fallback && created.code === "forbidden") created = await make("private"); |
| 809 | if (!created.ok) return { text: `The doc to keep it in couldn't be made: ${created.message}`, outcome: "refused" }; |
| 810 | ref = created.value; |
| 811 | if (this.othersHere(asker)) { |
| 812 | const check = await folios.read(asker, audience, ref.id).catch(() => null); |
| 813 | hidden = !check?.ok || !check.value.audience_can_read; |
| 814 | } |
| 815 | } |
| 816 | const kept = await folios.attach(asker, ref.id, { name: file.name, content_type: file.content_type, bytes: file.bytes }); |
| 817 | if (!kept.ok) return { text: `The file was made but couldn't be kept: ${kept.message}`, outcome: "refused" }; |
| 818 | const size = sizeLabel(kept.value.bytes); |
| 819 | // The doc links its file, so whoever opens the doc finds it. |
| 820 | await folios |
| 821 | .edit(asker, ref.id, { kind: "doc", target: { kind: "append" }, markdown: `**File:** [${kept.value.name.replace(/[[\]]/g, "")}](${kept.value.url}) (${size})`, note: `Attached ${kept.value.name}`, suggest_only: false, marks_current: false }) |
| 822 | .catch(() => null); |
| 823 | if (hidden) return this.notForEveryone(asker, ref, folios, "made"); |
| 824 | return { |
| 825 | text: `Made ${kept.value.name} (${file.summary}, ${size}) and kept it with the doc ${ref.title} (${ref.path}). Give them the file's link, ${kept.value.url}, and the doc's if it helps.`, |
| 826 | outcome: "allowed", |
| 827 | }; |
| 828 | } |
| 829 | |
| 830 | /** |
| 831 | * Where a new artifact goes: |
| 832 | * - a space named by its name or id, among those everyone here can read; |
| 833 | * - "private": the asker's Private; |
| 834 | * - "conversation": Private, plus `view` for this conversation's people; |
| 835 | * - nothing: the conversation in a DM or private channel, and in a public |
| 836 | * channel the General space if the asker can add there (`fallback`: |
| 837 | * their Private if the docs service says they can't). |
| 838 | */ |
| 839 | private async whereFor(given: unknown, asker: User, folios: FoliosPorts): Promise<{ ok: true; where: FolioWhere; fallback: boolean } | { ok: false; message: string }> { |
| 840 | const named = typeof given === "object" && given !== null ? (given as { space?: unknown }).space : given; |
| 841 | const wanted = typeof named === "string" ? named.trim().slice(0, 200) : ""; |
| 842 | const others = this.audience.members.filter((m) => m.id !== asker.id).map((m) => m.id); |
| 843 | const byDefault = async (): Promise<{ ok: true; where: FolioWhere; fallback: boolean }> => { |
| 844 | if (!this.audience.shared) return { ok: true, where: others.length ? { conversation: this.audience.members.map((m) => m.id) } : "private", fallback: false }; |
| 845 | const spaces = (await folios.spaces(asker, this.folioAudience())) ?? []; |
| 846 | const general = spaces.find((s) => s.slug === "general") ?? spaces.find((s) => s.name.toLowerCase() === "general"); |
| 847 | return general && general.can.suggest ? { ok: true, where: { space_id: general.id }, fallback: true } : { ok: true, where: "private", fallback: false }; |
| 848 | }; |
| 849 | if (!wanted) return byDefault(); |
| 850 | const lower = wanted.toLowerCase(); |
| 851 | if (typeof given === "string" && lower === "private") return { ok: true, where: "private", fallback: false }; |
| 852 | // A public channel has no list of people to share with: its default instead. |
| 853 | if (typeof given === "string" && lower === "conversation") return byDefault(); |
| 854 | const space = findSpace((await folios.spaces(asker, this.folioAudience())) ?? [], wanted); |
| 855 | if (space) return { ok: true, where: { space_id: space.id }, fallback: false }; |
| 856 | // An id the asker gave, for a space not everyone here can read: the docs service checks it. |
| 857 | if (/^spc_[A-Za-z0-9]+$/.test(wanted)) return { ok: true, where: { space_id: wanted }, fallback: false }; |
| 858 | return { ok: false, message: `There's no space called ${wanted} that everyone here can read. Use list_spaces, or put it in "private" or "conversation".` }; |
| 859 | } |
| 860 | |
| 861 | /** |
| 862 | * An artifact someone here can't read: the link goes to the asker |
| 863 | * directly, and the agent says only that it found or made something, |
| 864 | * never what. |
| 865 | */ |
| 866 | private async notForEveryone(asker: User, folio: FolioRef, folios: FoliosPorts, what: "found" | "made"): Promise<ToolResult> { |
| 867 | this.notHere.add(folio.id); |
| 868 | const who = `@${asker.username}`; |
| 869 | const note = |
| 870 | what === "made" |
| 871 | ? "I made this for you. Not everyone in the conversation you asked from can open it, so here is the link:" |
| 872 | : "Here is what you asked about. Not everyone in the conversation you asked from can open it, so here is the link:"; |
| 873 | const sent = await folios.sendLink(asker, { title: folio.title, path: folio.path }, note).catch(() => false); |
| 874 | const lead = `Not everyone in this conversation can read this artifact, so ${what === "made" ? "it" : "its content"} isn't shown here. Don't quote it, name it or describe it here;`; |
| 875 | const text = sent |
| 876 | ? `${lead} say you ${what} it and that you've sent the link to ${who} directly.` |
| 877 | : what === "made" |
| 878 | ? `${lead} say you made it and that ${who} will find it under Artifacts, in their Private or shared with them.` |
| 879 | : `${lead} say you found it but can't share it here, and ask ${who} to message you directly.`; |
| 880 | return { text, outcome: what === "made" ? "allowed" : "withheld" }; |
| 881 | } |
| 882 | |
| 883 | /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */ |
| 884 | private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> { |
| 885 | const actions = this.actions; |
| 886 | const offered = this.definitions().some((tool) => tool.name === name); |
| 887 | if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" }; |
| 888 | const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" }); |
| 889 | const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max); |
| 890 | switch (name) { |
| 891 | case "remember": { |
| 892 | const fact = text("fact", 2000); |
| 893 | if (!fact) return { text: "Say what to remember.", outcome: "refused" }; |
| 894 | const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null; |
| 895 | return said(await actions.remember(fact, scope, this.privateRead)); |
| 896 | } |
| 897 | case "forget": |
| 898 | return said(await actions.forget(text("id", 100))); |
| 899 | case "draft_issue": { |
| 900 | if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld(); |
| 901 | const repo = await this.audience.repo(input.repo); |
| 902 | if (!repo) return this.withheld(); |
| 903 | const title = text("title", 200); |
| 904 | const body = text("body", 20_000); |
| 905 | if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" }; |
| 906 | const labels = Array.isArray(input.labels) |
| 907 | ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5) |
| 908 | : []; |
| 909 | return said(await actions.draftIssue(repo, { title, body, labels })); |
| 910 | } |
| 911 | case "comment": |
| 912 | case "review_pull": { |
| 913 | const asker = this.audience.asker; |
| 914 | if (!asker || !this.audience.codeAllowed()) return this.withheld(); |
| 915 | const repo = await this.audience.repo(input.repo); |
| 916 | if (!repo) return this.withheld(); |
| 917 | const number = Math.floor(Number(input.number)); |
| 918 | if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" }; |
| 919 | const body = text("body", 20_000); |
| 920 | if (name === "comment") { |
| 921 | if (!body) return { text: "Say what to comment.", outcome: "refused" }; |
| 922 | return said(await actions.comment!(repo, asker, number, body)); |
| 923 | } |
| 924 | const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment"; |
| 925 | if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" }; |
| 926 | return said(await actions.review!(repo, asker, number, verdict, body)); |
| 927 | } |
| 928 | case "start_session": { |
| 929 | const title = text("title", 120); |
| 930 | const goal = text("goal", 8000); |
| 931 | if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" }; |
| 932 | return said(await actions.startSession!(title, goal)); |
| 933 | } |
| 934 | case "post_update": { |
| 935 | const note = text("text", 2000); |
| 936 | if (!note) return { text: "Say what to post.", outcome: "refused" }; |
| 937 | if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" }; |
| 938 | this.updates++; |
| 939 | return said(await actions.postUpdate!(note)); |
| 940 | } |
| 941 | case "use_subagent": { |
| 942 | const helper = text("name", 60).toLowerCase(); |
| 943 | const brief = text("brief", 8000); |
| 944 | if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" }; |
| 945 | return said(await actions.useSubagent!(helper, brief)); |
| 946 | } |
| 947 | case "bring_in": { |
| 948 | const handle = text("handle", 60).replace(/^@/, "").toLowerCase(); |
| 949 | const brief = text("brief", 8000); |
| 950 | if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" }; |
| 951 | 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" }; |
| 952 | return said(await actions.bringIn!(handle, brief)); |
| 953 | } |
| 954 | case "hand_off": { |
| 955 | const handle = text("handle", 60).replace(/^@/, "").toLowerCase(); |
| 956 | const brief = text("brief", 8000); |
| 957 | if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" }; |
| 958 | if (this.context.notConsult.includes(handle)) { |
| 959 | return { text: `You can't hand work to @${handle}: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" }; |
| 960 | } |
| 961 | if (this.handOffs >= MAX_HAND_OFFS) return { text: `You've handed off to ${MAX_HAND_OFFS} colleagues from this message; hand off the rest later.`, outcome: "refused" }; |
| 962 | const done = await actions.handOff!(handle, brief); |
| 963 | if (done.ok) this.handOffs++; |
| 964 | return said(done); |
| 965 | } |
| 966 | default: |
| 967 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> { |
| 972 | if (name === "list_repositories") { |
| 973 | const repos = [...(await this.audience.repos()).values()]; |
| 974 | if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" }; |
| 975 | return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" }; |
| 976 | } |
| 977 | if (name === "search_code") { |
| 978 | const query = String(input.query ?? "").trim(); |
| 979 | if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" }; |
| 980 | const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo); |
| 981 | if (input.repo && !only) return this.withheld(); |
| 982 | const allowed = await this.audience.repos(); |
| 983 | // Whatever search returns, only hits in allowed repositories come through. |
| 984 | 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())); |
| 985 | if (!hits.length) return { text: "No code found.", outcome: "allowed" }; |
| 986 | return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" }; |
| 987 | } |
| 988 | if (name === "recent_activity") { |
| 989 | const one = input.repo ? await this.audience.repo(input.repo) : null; |
| 990 | if (input.repo && !one) return this.withheld(); |
| 991 | const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20); |
| 992 | if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" }; |
| 993 | const pulls = await this.ports.recentPulls(repos, viewer); |
| 994 | if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" }; |
| 995 | return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" }; |
| 996 | } |
| 997 | // The rest name one repository; it must be on the allow-list. |
| 998 | const repo = await this.audience.repo(input.repo); |
| 999 | if (!repo) return this.withheld(); |
| 1000 | const full = `${repo.namespace}/${repo.name}`; |
| 1001 | switch (name) { |
| 1002 | case "read_file": { |
| 1003 | const path = String(input.path ?? "").trim().replace(/^\/+/, ""); |
| 1004 | if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" }; |
| 1005 | const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch; |
| 1006 | const file = await this.ports.readFile(repo, viewer, ref, path); |
| 1007 | if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" }; |
| 1008 | if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" }; |
| 1009 | return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" }; |
| 1010 | } |
| 1011 | case "list_issues": { |
| 1012 | const state = input.state === "closed" ? "closed" : "open"; |
| 1013 | const issues = await this.ports.listIssues(repo, viewer, state); |
| 1014 | if (!issues) return this.withheld(); |
| 1015 | if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" }; |
| 1016 | 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" }; |
| 1017 | } |
| 1018 | case "get_issue": { |
| 1019 | const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer); |
| 1020 | if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" }; |
| 1021 | const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n"); |
| 1022 | return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" }; |
| 1023 | } |
| 1024 | case "get_pull": { |
| 1025 | const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer); |
| 1026 | if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" }; |
| 1027 | return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" }; |
| 1028 | } |
| 1029 | default: |
| 1030 | return { text: `There is no tool called ${name}.`, outcome: "refused" }; |
| 1031 | } |
| 1032 | } |
| 1033 | } |
| 1034 | |
| 1035 | /** What an agent hears when it asks for a kind that isn't built yet. */ |
| 1036 | const NOT_YET = "Slides, designs and dashboards aren't available yet: only docs can be made for now. Say so, and offer to write it as a doc instead."; |
| 1037 | |
| 1038 | /** |
| 1039 | * A folio id from an id or any artifact link (`/acme/-/artifacts/runbook-fol_…`, |
| 1040 | * with or without the site and a query); null when there is none. |
| 1041 | */ |
| 1042 | /** |
| 1043 | * What a doc made to hold a file says: the Markdown the file was made from |
| 1044 | * (less a first heading repeating the title, which the doc has), or a |
| 1045 | * preview of a spreadsheet's rows. |
| 1046 | */ |
| 1047 | export function docBody(format: MakeFileFormat, title: string, input: Record<string, unknown>): string { |
| 1048 | let body = ""; |
| 1049 | if (format === "xlsx" || format === "csv") { |
| 1050 | const read = readSheets(input.sheets); |
| 1051 | if (read.ok) body = read.sheets.map((sheet) => `${read.sheets.length > 1 ? `## ${sheet.name}\n\n` : ""}${previewTable(sheet)}`).join("\n\n"); |
| 1052 | } else { |
| 1053 | const markdown = String(input.content ?? ""); |
| 1054 | const first = /^\s*#\s+(.+?)\s*#*\s*(?:\n|$)/.exec(markdown); |
| 1055 | body = (first && first[1]!.trim().toLowerCase() === title.trim().toLowerCase() ? markdown.slice(first[0].length).trimStart() : markdown).slice(0, 100_000); |
| 1056 | } |
| 1057 | return body.trim() ? body : "The file is attached below."; |
| 1058 | } |
| 1059 | |
| 1060 | export function folioRef(given: string): string | null { |
| 1061 | const last = given.trim().split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? ""; |
| 1062 | return folioIdFrom(last); |
| 1063 | } |
| 1064 | |
| 1065 | /** Where an artifact was written up from: a thread's link, cut to the site path the docs service keeps; null when it isn't one. */ |
| 1066 | export function sourceLink(given: string): { title: string; href: string } | null { |
| 1067 | let href = given.trim(); |
| 1068 | if (!href) return null; |
| 1069 | if (/^https?:\/\//i.test(href)) { |
| 1070 | try { |
| 1071 | const url = new URL(href); |
| 1072 | href = `${url.pathname}${url.search}${url.hash}`; |
| 1073 | } catch { |
| 1074 | return null; |
| 1075 | } |
| 1076 | } |
| 1077 | return href.startsWith("/") && !href.startsWith("//") ? { title: "A conversation", href } : null; |
| 1078 | } |
| 1079 | |
| 1080 | /** A space by its id, address or name (any case), among those given. */ |
| 1081 | function findSpace(spaces: FolioSpaceLine[], given: string): FolioSpaceLine | null { |
| 1082 | const wanted = given.trim().toLowerCase(); |
| 1083 | return spaces.find((s) => s.id === given.trim()) ?? spaces.find((s) => s.slug.toLowerCase() === wanted) ?? spaces.find((s) => s.name.toLowerCase() === wanted) ?? null; |
| 1084 | } |
| 1085 | |
| 1086 | function spaceLine(s: FolioSpaceLine): string { |
| 1087 | const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only"; |
| 1088 | const projects = s.projects.length ? `; about ${s.projects.join(", ")}` : ""; |
| 1089 | return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`; |
| 1090 | } |
| 1091 | |
| 1092 | /** An artifact as the agent reads it: where it is, what it may do, and its content (a doc's Markdown, with its top-level block ids). */ |
| 1093 | export function folioReadText(read: FolioAgentRead): string { |
| 1094 | const f = read.folio; |
| 1095 | const can = read.can.edit ? "you can edit it" : read.can.suggest ? "you can suggest edits" : "you can only read it"; |
| 1096 | const where = read.space ? `in the ${read.space.name} space` : "not in a space"; |
| 1097 | const blocks = read.blocks?.length ? `\nTop-level blocks: ${read.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ")}` : ""; |
| 1098 | return `# ${f.title} (${f.path}, id ${f.id})\nA ${f.kind}, ${where}; ${can}. Edited ${f.edited_at.slice(0, 16)}.${blocks}\n\n${read.content}`; |
| 1099 | } |
| 1100 | |
| 1101 | /** What an edit did, naming the artifact only where everyone here can read it. */ |
| 1102 | function editMessage(result: FolioAgentEditResult, name: boolean): string { |
| 1103 | const on = name ? ` on ${result.folio.title} (${result.folio.path})` : ""; |
| 1104 | switch (result.mode) { |
| 1105 | case "applied": |
| 1106 | return `Changed${on}. It's in its history as yours.`; |
| 1107 | case "suggested": |
| 1108 | return `Suggested${on}: people accept or reject it there.${name ? " Link it so they can." : ""}`; |
| 1109 | case "proposed": |
| 1110 | return `Proposed a change${on}: a person previews and applies it.`; |
| 1111 | } |
| 1112 | } |
| 1113 | |
| 1114 | function messageLine(m: FoundMessage): string { |
| 1115 | const where = m.channel ? `#${m.channel}` : "a direct message"; |
| 1116 | return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`; |
| 1117 | } |