Skip to content
132 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents → Skills is the workspace's skill library in the open SKILL.md format: write a skill in g1t, import a SKILL.md or zip, link a repository whose .g1t/skills folders publish on every push, or save a finished session as a skill once a person reviews it; attach a skill to an agent, a team or the whole workspace, each attachment pinned to a version. Agents see each skill's name and when to use it, and read the rest with use_skill when a request needs it; a skill never adds a permission, and scripts wait for an agent's own computer. The agent skills guide says how.1/**
2 * The skill library's RPC methods (@g1t/contracts skill-library.ts
3 * `skillLibraryClient`), and what the library reads from identity, repos
4 * and chat for them. The rules are in skill-library.ts.
5 */
6import { type RepoPath, type Result, type User, chatClient, fail, identityClient, ok, reposClient } from "@g1t/contracts";
7
8import { metered } from "./meter.ts";
9import { type SessionEnv, sessionRow } from "./sessions.ts";
10import { saveDraft, DRAFT_SYSTEM, transcriptText } from "./skill-draft.ts";
11import { Library, type LibraryPorts, onPush } from "./skill-library.ts";
12import type { Row } from "./store.ts";
13import { runTurn } from "./turn.ts";
14import type { ViewContext } from "./views.ts";
15
16/** Reads repositories without a viewer: only after the viewer's access was checked, or for a linked repository. */
17function repoFiles(env: { REPOS: SessionEnv["REPOS"] }): Pick<LibraryPorts, "listFiles" | "blobs"> {
18 const repos = reposClient(env.REPOS);
19 return {
20 listFiles: (repoId, ref) => repos.listFiles(repoId, ref, 10_000),
21 blobs: async (repoId, hashes) => (await repos.rawBlobs(repoId, hashes, 1024 * 1024)).map((blob) => ({ hash: blob.hash, data: blob.data })),
22 };
23}
24
25export function libraryFor(ctx: ViewContext, audit: (action: string, name: string, message: string) => void): Library {
26 const env = ctx.env;
27 const identity = identityClient(env.IDENTITY);
28 const ports: LibraryPorts = {
29 teams: async () => {
30 const listed = await identity.listTeams(ctx.viewer, ctx.slug).catch(() => null);
31 return listed?.ok ? listed.value.map((t) => ({ slug: t.slug, name: t.name, can_manage: t.can_manage })) : null;
32 },
33 repo: async (full) => {
34 const [namespace, name] = full.split("/") as [string, string];
35 const found = await reposClient(env.REPOS)
36 .get({ namespace, name } as RepoPath, ctx.viewer)
37 .catch(() => null);
38 return found?.ok ? { id: found.value.id, full: `${found.value.namespace}/${found.value.name}`, default_branch: found.value.defaultBranch } : null;
39 },
40 ...repoFiles(env),
41 agentTeams: async (agent) => (await identity.agentTeams(ctx.slug, agent.id, agent.team)).map((t) => ({ slug: t.slug, name: t.name })),
42 audit,
43 };
44 return new Library({ db: ctx.db, workspaceId: ctx.workspaceId, slug: ctx.slug, viewer: { id: ctx.viewer.id, username: ctx.viewer.username, kind: ctx.viewer.kind }, owner: ctx.owner, ports });
45}
46
47/** Save as skill: the session's agent drafts it from the transcript, on its own budget. */
48async function draftSkill(ctx: ViewContext, library: Library, id: unknown): Promise<Result<unknown>> {
49 if ((ctx.viewer.kind ?? "user") !== "user") return fail("forbidden", "People save sessions as skills.");
50 const row = await sessionRow(ctx.db, String(id ?? ""));
51 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
52 const audience = await chatClient(ctx.env.CHAT)
53 .audience(ctx.slug, row.channel_id)
54 .catch(() => null);
55 const visible = !!audience?.ok && (audience.value.kind === "public" || audience.value.member_user_ids.includes(ctx.viewer.id));
56 if (!visible) return fail("not_found", "There is no such session.");
57 if (row.status !== "done") return fail("invalid", "Save a session as a skill once it is done.");
58 const agent = await ctx.db.prepare("SELECT * FROM agents WHERE id = ?").bind(row.agent_id).first<Row>();
59 if (!agent) return fail("not_found", "The session's agent is gone.");
60 const events = await ctx.db
61 .prepare("SELECT kind, by_name, body, tool FROM agent_session_events WHERE session_id = ? ORDER BY seq LIMIT 600")
62 .bind(row.id)
63 .all<{ kind: string; by_name: string | null; body: string; tool: string | null }>();
64 const transcript = transcriptText({ title: row.title, goal: row.goal, result: row.summary }, events.results);
65 return saveDraft(library, { id: row.id, title: row.title, agent_handle: agent.handle }, async () => {
66 const done = await metered(
67 ctx.env,
68 { row: agent, payer: agent, slug: ctx.slug, task: "session", start: "small", askerName: ctx.viewer.username, person: ctx.viewer.username },
69 async (model) => {
70 const answer = await runTurn(model.send, {
71 model: model.model.model,
72 system: DRAFT_SYSTEM,
73 messages: [{ role: "user", content: transcript }],
74 tools: null,
75 price: model.ownModel ? null : model.model.price,
76 maxRounds: 1,
77 maxOutput: 4096,
78 });
79 return { ...answer, cost: model.ownModel ? 0 : answer.cost };
80 },
81 ).catch((error: unknown) => ({ ok: false as const, reason: "error", message: error instanceof Error ? error.message : String(error) }));
82 if (!done.ok) return fail(done.reason === "error" ? "unavailable" : "limit", done.reason === "error" ? "The draft couldn't be written just now. Try again in a moment." : done.message);
83 return ok(done.value.text);
84 });
85}
86
87/** The library's methods, or null for one that isn't the library's. */
88export async function skillRpc(
89 method: string,
90 args: any,
91 view: <T>(a: { workspace: string; viewer: User | null }, run: (ctx: ViewContext) => Promise<Result<T>>) => Promise<Result<T>>,
92 audit: (viewer: User, workspace: string, action: string, name: string, message: string) => void,
93): Promise<Result<unknown> | null> {
94 const library = (ctx: ViewContext) => libraryFor(ctx, (action, name, message) => audit(ctx.viewer, ctx.slug, action, name, message));
95 switch (method) {
96 case "skill_library":
97 return view(args, (ctx) => library(ctx).library());
98 case "skill":
99 return view(args, (ctx) => library(ctx).detail(args.name, args.version));
100 case "save_skill":
101 return view(args, (ctx) => library(ctx).save(args.name, args.input));
102 case "import_skill":
103 return view(args, (ctx) => library(ctx).import(args.source, args.replace === true));
104 case "attach_skill":
105 return view(args, (ctx) => library(ctx).attach(args.name, args.scope, args.target));
106 case "detach_skill":
107 return view(args, (ctx) => library(ctx).detach(args.name, args.attachment));
108 case "pin_skill":
109 return view(args, (ctx) => library(ctx).pin(args.name, args.attachment, args.version ?? null));
110 case "delete_skill":
111 return view(args, (ctx) => library(ctx).remove(args.name));
112 case "agent_skills":
113 return view(args, (ctx) => library(ctx).agentSkills(args.handle));
114 case "draft_skill":
115 return view(args, (ctx) => draftSkill(ctx, library(ctx), args.session));
116 case "set_skill_mirror":
117 return view(args, (ctx) => library(ctx).setMirror(args.repo ?? null));
118 case "sync_skill_mirror":
119 return view(args, (ctx) => library(ctx).sync());
120 default:
121 return null;
122 }
123}
124
125/** After pushes: libraries that follow a pushed repository's default branch read it again. */
126export async function skillPushes(env: { DB: D1Database; REPOS: SessionEnv["REPOS"] }, pushed: { repoId: string }[]): Promise<void> {
127 const repos = [...new Set(pushed.map((p) => p.repoId))];
128 for (const repoId of repos) {
129 await onPush(env.DB, repoFiles(env), repoId).catch((error: unknown) => console.error("agents: skills not read after a push", repoId, String(error)));
130 }
131}
132