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