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 | */ | |
| 6 | import { type RepoPath, type Result, type User, chatClient, fail, identityClient, ok, reposClient } from "@g1t/contracts"; | |
| 7 | ||
| 8 | import { metered } from "./meter.ts"; | |
| 9 | import { type SessionEnv, sessionRow } from "./sessions.ts"; | |
| 10 | import { saveDraft, DRAFT_SYSTEM, transcriptText } from "./skill-draft.ts"; | |
| 11 | import { Library, type LibraryPorts, onPush } from "./skill-library.ts"; | |
| 12 | import type { Row } from "./store.ts"; | |
| 13 | import { runTurn } from "./turn.ts"; | |
| 14 | import 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. */ | |
| 17 | function 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 | ||
| 25 | export 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. */ | |
| 48 | async 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. */ | |
| 88 | export 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. */ | |
| 126 | export 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 |