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