Skip to content
257 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 Result,
9 type ServiceBinding,
10 type User,
11 chatClient,
12 docsClient,
13 foliosClient,
14 identityClient,
15 reposClient,
16 searchClient,
17 workClient,
18} from "@g1t/contracts";
19
20import type { AudiencePorts, RepoRef } from "./audience.ts";
21import type { FolioDone, FoliosPorts, FoundMessage, ToolPorts } from "./tools.ts";
22import { RECALL_LIMIT, passageSource } from "./recall.ts";
23import { base64 } from "./files.ts";
24
25export type PortsEnv = {
26 DB: D1Database;
27 CHAT: ServiceBinding;
28 IDENTITY: ServiceBinding;
29 REPOS: ServiceBinding;
30 WORK: ServiceBinding;
31 SEARCH: ServiceBinding;
32 /** The docs service, for agents reading and writing artifacts; absent on an installation without it. */
33 DOCS?: ServiceBinding;
34 /**
35 * Where files kept with artifacts are served (g1tusercontent.com for
36 * g1t.sh), for the links to files agents make. Empty: the site's own
37 * `/-/usercontent` path.
38 */
39 USERCONTENT_URL?: string;
40};
41
42/** The usercontent origin files are linked under, without a trailing slash. */
43export function usercontentBase(env: { USERCONTENT_URL?: string }): string {
44 return (env.USERCONTENT_URL ?? "").trim().replace(/\/+$/, "") || "/-/usercontent";
45}
46
47const refOf = (repo: { id: string; namespace: string; name: string; isPrivate: boolean; defaultBranch: string; forkOf?: string | null }): RepoRef => ({
48 id: repo.id,
49 namespace: repo.namespace,
50 name: repo.name,
51 isPrivate: repo.isPrivate,
52 defaultBranch: repo.defaultBranch,
53 forkOf: repo.forkOf ?? null,
54});
55
56export function audiencePorts(env: PortsEnv, workspace: string, channelId: string): AudiencePorts {
57 const repos = reposClient(env.REPOS);
58 return {
59 async info() {
60 const found = await chatClient(env.CHAT).audience(workspace, channelId);
61 if (!found.ok) throw new Error(`the conversation's audience could not be read: ${found.error.message}`);
62 return found.value;
63 },
64 users: (ids) => identityClient(env.IDENTITY).usersForAudience(ids),
65 workspaceRepos: async (viewer) => (await repos.list(viewer, { namespace: workspace, memberOnly: true })).map(refOf),
66 readable: async (ids, viewer) => (await repos.readable(ids.slice(0, 500), viewer)).map(refOf),
67 };
68}
69
70const found = (m: { channel_id: string; channel: string | null; message: { id: string; author: { name: string }; body: string; created_at: string; card: { title: string } | null } }): FoundMessage => ({
71 channel: m.channel,
72 channel_id: m.channel_id,
73 id: m.message.id,
74 author: m.message.author.name,
75 body: m.message.body || (m.message.card ? `[card: ${m.message.card.title}]` : ""),
76 created_at: m.message.created_at,
77});
78
79export function toolPorts(
80 env: PortsEnv,
81 workspace: string,
82 workspaceId: string,
83 channelId: string,
84 consult: ToolPorts["consult"],
85 agentId: string | null = null,
86): ToolPorts {
87 const repos = reposClient(env.REPOS);
88 const work = workClient(env.WORK);
89 const path = (repo: RepoRef) => ({ namespace: repo.namespace, name: repo.name });
90 return {
91 async readFile(repo, viewer, ref, file) {
92 const blob = await repos.blob(path(repo), viewer, ref, file);
93 return blob.ok ? { text: blob.value.text, size: blob.value.size } : null;
94 },
95 async searchCode(viewer, query, repo) {
96 const results = await searchClient(env.SEARCH).search(viewer, repo ? `${query} repo:${repo.namespace}/${repo.name}` : query, { type: "code", perPage: 20 });
97 if (!results.ok) return [];
98 return results.value.hits
99 .filter((hit) => hit.kind === "code" && hit.repo && hit.path)
100 .map((hit) => ({
101 repo: hit.repo!,
102 path: hit.path!,
103 snippet: hit.lines.map((line) => `${line.number}: ${line.parts.map((part) => part.text).join("")}`).join("\n"),
104 }));
105 },
106 async listIssues(repo, viewer, state) {
107 const issues = await work.listIssues(path(repo), viewer, { state });
108 return issues.ok ? issues.value.map((i) => ({ number: i.number, title: i.title, state: i.state, labels: i.labels })) : null;
109 },
110 async getIssue(repo, number, viewer) {
111 const detail = await work.getIssue(path(repo), number, viewer);
112 if (!detail.ok) return null;
113 const { issue, comments } = detail.value;
114 return {
115 number: issue.number,
116 title: issue.title,
117 state: issue.state,
118 body: issue.body,
119 comments: comments.filter((c) => c.kind === "comment").slice(-20).map((c) => ({ author: c.author.username, body: c.body })),
120 };
121 },
122 async getPull(repo, number, viewer) {
123 const detail = await work.getPull(path(repo), number, viewer);
124 if (!detail.ok) return null;
125 const { pull, checks } = detail.value;
126 return {
127 number: pull.number,
128 title: pull.title,
129 status: pull.status,
130 body: pull.body ?? "",
131 checks: checks ? `${checks.status}${checks.results.length ? `: ${checks.results.map((r) => `${r.command} ${r.passed ? "passed" : "failed"}`).join(", ")}` : ""}` : null,
132 };
133 },
134 async recentPulls(list, viewer) {
135 const byId = new Map(list.map((repo) => [repo.id, repo]));
136 const groups = await work.pullsForRepos([...byId.keys()], viewer, 5);
137 return groups
138 .flatMap((group) => [...group.open, ...group.closed].map((pull) => ({ pull, repo: byId.get(group.repoId) })))
139 .filter((entry) => entry.repo)
140 .map(({ pull, repo }) => ({
141 repo: `${repo!.namespace}/${repo!.name}`,
142 number: pull.number,
143 title: pull.title,
144 status: pull.status,
145 updated_at: pull.mergedAt ?? pull.updatedAt,
146 }))
147 .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
148 .slice(0, 25);
149 },
150 async searchMessages(query) {
151 const result = await chatClient(env.CHAT).searchForAgent(workspace, channelId, query, 20);
152 return result.ok ? result.value.map(found) : null;
153 },
154 async readThread(target, id) {
155 const result = await chatClient(env.CHAT).threadForAgent(workspace, channelId, target, id);
156 return result.ok ? result.value.map(found) : null;
157 },
158 async roster(viewer: User | null) {
159 const identity = identityClient(env.IDENTITY);
160 const [members, teams, agents] = await Promise.all([
161 viewer ? identity.listMembers(workspace, viewer).catch(() => null) : Promise.resolve(null),
162 viewer ? identity.listTeams(viewer, workspace).catch(() => null) : Promise.resolve(null),
163 env.DB.prepare("SELECT handle, display_name, title, role FROM agents WHERE workspace_id = ? AND archived_at IS NULL ORDER BY builtin DESC, handle")
164 .bind(workspaceId)
165 .all<{ handle: string; display_name: string; title: string; role: string }>(),
166 ]);
167 const people = members?.ok ? members.value.map((m) => `- @${m.username}${m.name ? ` (${m.name})` : ""}, ${m.role}`).join("\n") : "(people could not be listed)";
168 const teamLines = teams?.ok ? teams.value.map((t) => `- ${t.slug}: ${t.name}${t.description ? `, ${t.description}` : ""}`).join("\n") : "";
169 const agentLines = agents.results.map((a) => `- @${a.handle} (${a.display_name}): ${a.title || a.role}`).join("\n");
170 return `People:\n${people}${teamLines ? `\n\nTeams:\n${teamLines}` : ""}\n\nAgents:\n${agentLines}`;
171 },
172 consult,
173 ...(env.DOCS && agentId ? { folios: folioPorts(env.DOCS, env.CHAT, workspace, agentId, usercontentBase(env)) } : {}),
174 };
175}
176
177/** A call's result as the tools take it: the value, or the error's code and sentence. */
178const done = <T>(result: Result<T>): FolioDone<T> => (result.ok ? result : { ok: false, code: result.error.code, message: result.error.message });
179
180/**
181 * Artifacts (folios) as an agent reads and writes them: Markdown in,
182 * Markdown out, every call checked by the docs service. Spaces still come
183 * from the docs service's `spaces_for_agent`: spaces are shared by pages
184 * and folios, and have no folio method of their own.
185 */
186function folioPorts(docs: ServiceBinding, chatBinding: ServiceBinding, workspace: string, agentId: string, usercontent: string): FoliosPorts {
187 const folios = foliosClient(docs);
188 const where = (f: { title: string; path: string; id: string }) => `${f.title} (${f.path}, id ${f.id})`;
189 return {
190 async spaces(viewer, audience) {
191 const found = await docsClient(docs).spacesForAgent(workspace, agentId, viewer, audience);
192 if (!found.ok) return null;
193 return found.value.map((s) => ({ id: s.id, slug: s.slug, name: s.name, description: s.description, kind: s.kind, projects: s.projects ?? [], can: s.can }));
194 },
195 async recall(viewer, audience, query, spaces, kinds) {
196 const found = await folios.recallForAgent(workspace, agentId, viewer, { query, limit: RECALL_LIMIT, spaces, kinds: kinds ?? null }, audience);
197 return found.ok ? found.value : null;
198 },
199 async search(viewer, audience, input) {
200 const kinds = input.kind ? [input.kind] : null;
201 // Passages too, when the search isn't narrowed to a space or project: recall can't be.
202 const [list, passages] = await Promise.all([
203 folios.foliosForAgent(workspace, agentId, viewer, { tab: "all", q: input.query, kinds, space_id: input.space_id, project: input.project, limit: 10 }, audience),
204 input.space_id || input.project ? Promise.resolve(null) : folios.recallForAgent(workspace, agentId, viewer, { query: input.query, limit: 4, kinds }, audience).catch(() => null),
205 ]);
206 if (!list.ok) return null;
207 const lines = list.value.items.map((f) => {
208 const space = f.space ? `in ${f.space.name}` : "not in a space";
209 return `- ${where(f)}: a ${f.kind} ${space}, edited ${f.edited_at.slice(0, 10)}${f.stale ? ", possibly out of date" : ""}${f.excerpt ? `: ${f.excerpt.replace(/\s+/g, " ").slice(0, 300)}` : ""}`;
210 });
211 const found = passages?.ok ? passages.value.map((p) => `### ${passageSource(p)}\n${p.text.trim().slice(0, 800)}`) : [];
212 if (!lines.length && !found.length) return "No artifacts found.";
213 return [lines.length ? lines.join("\n") : "No artifacts matched by title or words.", ...(found.length ? ["", "Passages that match:", "", found.join("\n\n")] : [])].join("\n");
214 },
215 read: async (viewer, audience, folioId) => done(await folios.readForAgent(workspace, agentId, viewer, folioId, audience)),
216 async stale(viewer, audience, repo) {
217 const found = await folios.staleForAgent(workspace, agentId, viewer, { repo }, audience);
218 if (!found.ok) return null;
219 if (!found.value.length) return "No artifacts are marked possibly out of date.";
220 return found.value
221 .map((f) => {
222 const can = f.viewer_role === "edit" || f.viewer_role === "manage" ? (f.agent_mode === "edit" ? "you can edit" : "you can suggest") : f.viewer_role === "comment" ? "you can suggest" : "read only";
223 return `- ${where(f)}, a ${f.kind} ${f.space ? `in ${f.space.name}` : "not in a space"} (${can}), edited ${f.edited_at.slice(0, 10)}`;
224 })
225 .join("\n");
226 },
227 async create(viewer, input) {
228 const made = await folios.createAsAgent(workspace, agentId, viewer, {
229 kind: input.kind,
230 title: input.title,
231 content: input.markdown === null ? null : { markdown: input.markdown },
232 template_id: input.template_id,
233 where: input.where,
234 parent_id: input.parent_id,
235 source: input.source,
236 });
237 return done(made);
238 },
239 edit: async (viewer, folioId, edit) => done(await folios.editAsAgent(workspace, agentId, viewer, folioId, edit)),
240 async share(viewer, audience, folioId, userIds, role) {
241 const shared = await folios.shareAsAgent(workspace, agentId, viewer, folioId, { user_ids: userIds, role }, audience);
242 return shared.ok ? { ok: true, value: null } : done(shared);
243 },
244 async attach(viewer, folioId, file) {
245 const kept = await folios.attachAsAgent(workspace, agentId, viewer, folioId, { name: file.name, content_type: file.content_type, data: base64(file.bytes) });
246 return kept.ok ? { ok: true, value: { url: `${usercontent}${kept.value.url}`, name: kept.value.name, bytes: kept.value.bytes } } : done(kept);
247 },
248 async sendLink(asker, link, note) {
249 const chat = chatClient(chatBinding);
250 const dm = await chat.openDm(workspace, asker, [{ kind: "agent", id: agentId }]);
251 if (!dm.ok) return false;
252 // No mention, so it wakes nobody.
253 const posted = await chat.postAsAgent(workspace, dm.value.id, agentId, { body: `${note} [${link.title.replace(/[[\]]/g, "")}](${link.path})`, asked_by: asker.id });
254 return posted.ok;
255 },
256 };
257}