Skip to content
211 linesCodeBlameRaw
1/**
2 * The reply's audience and tools, wired to g1t's services. Each call here
3 * runs only after `Audience` and `ToolBox` decided it may (audience.ts,
4 * tools.ts); the backing services check the viewer again, as they do for
5 * every request.
6 */
7import {
8 type ServiceBinding,
9 type User,
10 type DocPageRef,
11 chatClient,
12 docsClient,
13 identityClient,
14 reposClient,
15 searchClient,
16 workClient,
17} from "@g1t/contracts";
18
19import type { AudiencePorts, RepoRef } from "./audience.ts";
20import type { DocsPorts, FoundMessage, ToolPorts } from "./tools.ts";
21
22export type PortsEnv = {
23 DB: D1Database;
24 CHAT: ServiceBinding;
25 IDENTITY: ServiceBinding;
26 REPOS: ServiceBinding;
27 WORK: ServiceBinding;
28 SEARCH: ServiceBinding;
29 /** Docs, for agents reading and writing pages; absent on an installation without it. */
30 DOCS?: ServiceBinding;
31};
32
33const refOf = (repo: { id: string; namespace: string; name: string; isPrivate: boolean; defaultBranch: string; forkOf?: string | null }): RepoRef => ({
34 id: repo.id,
35 namespace: repo.namespace,
36 name: repo.name,
37 isPrivate: repo.isPrivate,
38 defaultBranch: repo.defaultBranch,
39 forkOf: repo.forkOf ?? null,
40});
41
42export function audiencePorts(env: PortsEnv, workspace: string, channelId: string): AudiencePorts {
43 const repos = reposClient(env.REPOS);
44 return {
45 async info() {
46 const found = await chatClient(env.CHAT).audience(workspace, channelId);
47 if (!found.ok) throw new Error(`the conversation's audience could not be read: ${found.error.message}`);
48 return found.value;
49 },
50 users: (ids) => identityClient(env.IDENTITY).usersForAudience(ids),
51 workspaceRepos: async (viewer) => (await repos.list(viewer, { namespace: workspace, memberOnly: true })).map(refOf),
52 readable: async (ids, viewer) => (await repos.readable(ids.slice(0, 500), viewer)).map(refOf),
53 };
54}
55
56const found = (m: { channel_id: string; channel: string | null; message: { id: string; author: { name: string }; body: string; created_at: string; card: { title: string } | null } }): FoundMessage => ({
57 channel: m.channel,
58 channel_id: m.channel_id,
59 id: m.message.id,
60 author: m.message.author.name,
61 body: m.message.body || (m.message.card ? `[card: ${m.message.card.title}]` : ""),
62 created_at: m.message.created_at,
63});
64
65export function toolPorts(
66 env: PortsEnv,
67 workspace: string,
68 workspaceId: string,
69 channelId: string,
70 consult: ToolPorts["consult"],
71 agentId: string | null = null,
72): ToolPorts {
73 const repos = reposClient(env.REPOS);
74 const work = workClient(env.WORK);
75 const path = (repo: RepoRef) => ({ namespace: repo.namespace, name: repo.name });
76 return {
77 async readFile(repo, viewer, ref, file) {
78 const blob = await repos.blob(path(repo), viewer, ref, file);
79 return blob.ok ? { text: blob.value.text, size: blob.value.size } : null;
80 },
81 async searchCode(viewer, query, repo) {
82 const results = await searchClient(env.SEARCH).search(viewer, repo ? `${query} repo:${repo.namespace}/${repo.name}` : query, { type: "code", perPage: 20 });
83 if (!results.ok) return [];
84 return results.value.hits
85 .filter((hit) => hit.kind === "code" && hit.repo && hit.path)
86 .map((hit) => ({
87 repo: hit.repo!,
88 path: hit.path!,
89 snippet: hit.lines.map((line) => `${line.number}: ${line.parts.map((part) => part.text).join("")}`).join("\n"),
90 }));
91 },
92 async listIssues(repo, viewer, state) {
93 const issues = await work.listIssues(path(repo), viewer, { state });
94 return issues.ok ? issues.value.map((i) => ({ number: i.number, title: i.title, state: i.state, labels: i.labels })) : null;
95 },
96 async getIssue(repo, number, viewer) {
97 const detail = await work.getIssue(path(repo), number, viewer);
98 if (!detail.ok) return null;
99 const { issue, comments } = detail.value;
100 return {
101 number: issue.number,
102 title: issue.title,
103 state: issue.state,
104 body: issue.body,
105 comments: comments.filter((c) => c.kind === "comment").slice(-20).map((c) => ({ author: c.author.username, body: c.body })),
106 };
107 },
108 async getPull(repo, number, viewer) {
109 const detail = await work.getPull(path(repo), number, viewer);
110 if (!detail.ok) return null;
111 const { pull, checks } = detail.value;
112 return {
113 number: pull.number,
114 title: pull.title,
115 status: pull.status,
116 body: pull.body ?? "",
117 checks: checks ? `${checks.status}${checks.results.length ? `: ${checks.results.map((r) => `${r.command} ${r.passed ? "passed" : "failed"}`).join(", ")}` : ""}` : null,
118 };
119 },
120 async recentPulls(list, viewer) {
121 const byId = new Map(list.map((repo) => [repo.id, repo]));
122 const groups = await work.pullsForRepos([...byId.keys()], viewer, 5);
123 return groups
124 .flatMap((group) => [...group.open, ...group.closed].map((pull) => ({ pull, repo: byId.get(group.repoId) })))
125 .filter((entry) => entry.repo)
126 .map(({ pull, repo }) => ({
127 repo: `${repo!.namespace}/${repo!.name}`,
128 number: pull.number,
129 title: pull.title,
130 status: pull.status,
131 updated_at: pull.mergedAt ?? pull.updatedAt,
132 }))
133 .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
134 .slice(0, 25);
135 },
136 async searchMessages(query) {
137 const result = await chatClient(env.CHAT).searchForAgent(workspace, channelId, query, 20);
138 return result.ok ? result.value.map(found) : null;
139 },
140 async readThread(target, id) {
141 const result = await chatClient(env.CHAT).threadForAgent(workspace, channelId, target, id);
142 return result.ok ? result.value.map(found) : null;
143 },
144 async roster(viewer: User | null) {
145 const identity = identityClient(env.IDENTITY);
146 const [members, teams, agents] = await Promise.all([
147 viewer ? identity.listMembers(workspace, viewer).catch(() => null) : Promise.resolve(null),
148 viewer ? identity.listTeams(viewer, workspace).catch(() => null) : Promise.resolve(null),
149 env.DB.prepare("SELECT handle, display_name, title, role FROM agents WHERE workspace_id = ? AND archived_at IS NULL ORDER BY builtin DESC, handle")
150 .bind(workspaceId)
151 .all<{ handle: string; display_name: string; title: string; role: string }>(),
152 ]);
153 const people = members?.ok ? members.value.map((m) => `- @${m.username}${m.name ? ` (${m.name})` : ""}, ${m.role}`).join("\n") : "(people could not be listed)";
154 const teamLines = teams?.ok ? teams.value.map((t) => `- ${t.slug}: ${t.name}${t.description ? `, ${t.description}` : ""}`).join("\n") : "";
155 const agentLines = agents.results.map((a) => `- @${a.handle} (${a.display_name}): ${a.title || a.role}`).join("\n");
156 return `People:\n${people}${teamLines ? `\n\nTeams:\n${teamLines}` : ""}\n\nAgents:\n${agentLines}`;
157 },
158 consult,
159 ...(env.DOCS && agentId ? { docs: docsPorts(env.DOCS, workspace, agentId) } : {}),
160 };
161}
162
163/** Docs as an agent reads and writes them: Markdown in, Markdown out, every call checked by the docs service. */
164function docsPorts(binding: ServiceBinding, workspace: string, agentId: string): DocsPorts {
165 const docs = docsClient(binding);
166 const where = (page: DocPageRef) => `${page.title} (${page.path}, id ${page.id})`;
167 return {
168 async spaces(viewer, audience) {
169 const found = await docs.spacesForAgent(workspace, agentId, viewer, audience);
170 if (!found.ok) return null;
171 if (!found.value.length) return "There are no Docs spaces everyone here can read.";
172 return found.value
173 .map((s) => {
174 const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
175 const projects = s.projects?.length ? `; about ${s.projects.join(", ")}` : "";
176 return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
177 })
178 .join("\n");
179 },
180 async search(viewer, audience, query, project) {
181 const found = await docs.searchForAgent(workspace, agentId, viewer, { query, project, limit: 10 }, audience);
182 if (!found.ok) return null;
183 if (!found.value.length) return "No pages found.";
184 return found.value.map((hit) => `- ${where(hit)} in ${hit.space_name}, updated ${hit.updated_at.slice(0, 10)}: ${hit.snippet.replace(/\[\[|\]\]/g, "")}`).join("\n");
185 },
186 async read(viewer, audience, pageId) {
187 const found = await docs.pageMarkdown(workspace, agentId, viewer, pageId, audience);
188 if (!found.ok) return null;
189 const p = found.value;
190 const can = p.can.edit ? "you can edit it" : p.can.suggest ? "you can suggest edits" : "you can only read it";
191 const blocks = p.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ");
192 return `# ${where(p.page)}\nSpace: ${p.space.name}; ${can}. Updated ${p.page.updated_at.slice(0, 16)}.\nTop-level blocks: ${blocks}\n\n${p.markdown}`;
193 },
194 async edit(viewer, pageId, edit, suggestOnly) {
195 const done = suggestOnly
196 ? await docs.suggestEdit(workspace, agentId, viewer, pageId, edit).then((r) => (r.ok ? { ok: true as const, value: { mode: "suggested" as const, suggestion: r.value, page: null } } : r))
197 : await docs.applyEdit(workspace, agentId, viewer, pageId, edit);
198 if (!done.ok) return { ok: false, message: `That didn't work: ${done.error.message}` };
199 const v = done.value;
200 const page = v.page ? ` on ${where(v.page)}` : "";
201 return v.mode === "applied"
202 ? { ok: true, message: `Changed${page}. It's in the page's history as yours.` }
203 : { ok: true, message: `Suggested${page}: people accept or reject it on the page. Link the page so they can.` };
204 },
205 async create(viewer, input) {
206 const made = await docs.createPageAsAgent(workspace, agentId, viewer, input);
207 if (!made.ok) return { ok: false, message: `The page couldn't be made: ${made.error.message}` };
208 return { ok: true, message: `Wrote ${where(made.value)}. Link it.` };
209 },
210 };
211}