| 1 | /** |
| 2 | * The skill library (docs.g1t.sh/guides/agent-skills/, @g1t/contracts |
| 3 | * skill-library.ts): a workspace's own skills, every change a new version, |
| 4 | * attached to agents, teams or the whole workspace at a pinned version. |
| 5 | * |
| 6 | * - **Sources:** written in the editor; imported from an upload (SKILL.md |
| 7 | * or a zip) or a repository folder at one commit; drafted by an agent |
| 8 | * from a finished session and published once a person reviews it; or |
| 9 | * followed from the repository the library is linked to |
| 10 | * (`.g1t/skills/<name>/` on its default branch, read again after every |
| 11 | * push there). Writing back to that repository is coming. |
| 12 | * - **Who:** everyone in the workspace sees the library and can save a |
| 13 | * draft from a session they can see; team maintainers write and import |
| 14 | * skills, edit their own, publish drafts and attach to their teams; |
| 15 | * owners do everything, for any skill. |
| 16 | * - **Versions:** each attachment pins one. Saving moves the attachments |
| 17 | * the person saving may change (unless they say not to); the others show |
| 18 | * an update available. A push to the linked repository moves every |
| 19 | * attachment of the skills it changed: the repository's review is the |
| 20 | * review. |
| 21 | * - **Never a permission:** a skill names tools agents have; it gives none. |
| 22 | * |
| 23 | * Identity and repositories come through `LibraryPorts`, so this runs |
| 24 | * against SQLite in tests. |
| 25 | */ |
| 26 | import type { Result, SkillAttachment, SkillDetail, SkillFileEntry, SkillImport, SkillInput, SkillLibrary, SkillMirror, SkillOrigin, SkillScope, SkillStatus, SkillVersionEntry } from "@g1t/contracts"; |
| 27 | import type { AgentSkillLine, AgentSkills, LibrarySkill } from "../../../packages/contracts/src/skill-library.ts"; |
| 28 | |
| 29 | import { newId } from "../../../packages/contracts/src/ids.ts"; |
| 30 | import { fail, ok } from "../../../packages/contracts/src/result.ts"; |
| 31 | import { |
| 32 | type CheckedSkill, |
| 33 | type SkillFile, |
| 34 | SKILLS_PER_AGENT_MAX, |
| 35 | SKILLS_REPO_DIR, |
| 36 | SKILL_FILES_MAX, |
| 37 | SKILL_FOLDER_MAX_BYTES, |
| 38 | checkSkillFolder, |
| 39 | parseFrontMatter, |
| 40 | renderSkillMd, |
| 41 | skillFileBytes, |
| 42 | skillNameProblem, |
| 43 | splitFrontMatter, |
| 44 | } from "../../../packages/contracts/src/skill-format.ts"; |
| 45 | import { FOUNDATIONAL_SKILLS, FOUNDATIONAL_SKILLS_VERSION } from "../../../packages/contracts/src/skills.ts"; |
| 46 | import { asSkillFile, readUpload } from "./skill-zip.ts"; |
| 47 | import type { StoredVersion } from "./skills.ts"; |
| 48 | |
| 49 | /** The most skills one workspace's library holds. */ |
| 50 | export const LIBRARY_MAX = 1000; |
| 51 | /** Text files up to this size are shown on a skill's page. */ |
| 52 | const SHOWN_FILE_BYTES = 200 * 1024; |
| 53 | |
| 54 | export type SkillRow = { |
| 55 | id: string; |
| 56 | workspace_id: string; |
| 57 | name: string; |
| 58 | status: SkillStatus; |
| 59 | version: number; |
| 60 | description: string; |
| 61 | tools: string; |
| 62 | requires_computer: number; |
| 63 | files: number; |
| 64 | bytes: number; |
| 65 | origin: string; |
| 66 | mirrored: number; |
| 67 | created_by: string; |
| 68 | created_at: string; |
| 69 | updated_by: string; |
| 70 | updated_at: string; |
| 71 | }; |
| 72 | |
| 73 | export type AttachmentRow = { id: string; skill_id: string; scope: SkillScope; target: string; version: number; attached_by: string; attached_at: string }; |
| 74 | |
| 75 | type VersionRow = { |
| 76 | version: number; |
| 77 | description: string; |
| 78 | tools: string; |
| 79 | requires_computer: number; |
| 80 | skill_md: string; |
| 81 | files: string; |
| 82 | bytes: number; |
| 83 | origin: string; |
| 84 | note: string | null; |
| 85 | created_by: string; |
| 86 | created_at: string; |
| 87 | }; |
| 88 | |
| 89 | type MirrorRow = { workspace_id: string; repo_id: string; repo: string; branch: string; commit_sha: string | null; synced_at: string | null; error: string | null; linked_by: string; linked_at: string }; |
| 90 | |
| 91 | /** What the library needs from identity and repositories, as the viewer. */ |
| 92 | export type LibraryPorts = { |
| 93 | /** The workspace's teams the viewer can see, and whether they may manage each; null when identity didn't answer. */ |
| 94 | teams(): Promise<{ slug: string; name: string; can_manage: boolean }[] | null>; |
| 95 | /** A repository the viewer can read, by `workspace/name`. */ |
| 96 | repo(full: string): Promise<{ id: string; full: string; default_branch: string } | null>; |
| 97 | /** Every file at a branch, tag or commit (the default branch when null), without a viewer: check access first. */ |
| 98 | listFiles(repoId: string, ref: string | null): Promise<{ commit: string | null; files: { path: string; hash: string | null }[]; truncated: boolean }>; |
| 99 | /** Blobs as base64; null data for one missing or over 1 MB. */ |
| 100 | blobs(repoId: string, hashes: string[]): Promise<{ hash: string; data: string | null }[]>; |
| 101 | /** The visible teams an agent is on. */ |
| 102 | agentTeams(agentId: string): Promise<{ slug: string; name: string }[]>; |
| 103 | /** The workspace's visible teams, each with the agents on it, by id. */ |
| 104 | teamAgentIndex(): Promise<{ slug: string; agent_ids: string[] }[]>; |
| 105 | /** The workspace's audit log. */ |
| 106 | audit(action: string, name: string, message: string): void; |
| 107 | }; |
| 108 | |
| 109 | export type LibraryContext = { |
| 110 | db: D1Database; |
| 111 | workspaceId: string; |
| 112 | slug: string; |
| 113 | viewer: { id: string; username: string; kind?: string }; |
| 114 | /** Owns the workspace (or is its token). */ |
| 115 | owner: boolean; |
| 116 | ports: LibraryPorts; |
| 117 | now?: Date; |
| 118 | }; |
| 119 | |
| 120 | /** What the viewer may do: owners everything; maintainers for their teams. */ |
| 121 | export type Actor = { username: string; owner: boolean; maintains: ReadonlySet<string> }; |
| 122 | |
| 123 | export function mayWrite(actor: Actor): boolean { |
| 124 | return actor.owner || actor.maintains.size > 0; |
| 125 | } |
| 126 | |
| 127 | export function mayChange(actor: Actor, scope: SkillScope, target: string): boolean { |
| 128 | return actor.owner || (scope === "team" && actor.maintains.has(target)); |
| 129 | } |
| 130 | |
| 131 | export function mayEdit(actor: Actor, row: Pick<SkillRow, "status" | "created_by" | "mirrored">): boolean { |
| 132 | if (row.mirrored) return false; |
| 133 | if (!mayWrite(actor)) return false; |
| 134 | return actor.owner || row.status === "draft" || row.created_by === actor.username; |
| 135 | } |
| 136 | |
| 137 | export function mayDelete(actor: Actor, row: Pick<SkillRow, "status" | "created_by">, attachments: readonly Pick<AttachmentRow, "scope" | "target">[]): boolean { |
| 138 | if (actor.owner) return true; |
| 139 | if (row.status === "draft") return mayWrite(actor) || row.created_by === actor.username; |
| 140 | return mayWrite(actor) && row.created_by === actor.username && attachments.every((a) => a.scope === "team" && actor.maintains.has(a.target)); |
| 141 | } |
| 142 | |
| 143 | function json<T>(raw: string | null | undefined, fallback: T): T { |
| 144 | if (!raw) return fallback; |
| 145 | try { |
| 146 | return JSON.parse(raw) as T; |
| 147 | } catch { |
| 148 | return fallback; |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | export async function digestOf(skillMd: string, files: readonly SkillFile[]): Promise<string> { |
| 153 | const data = new TextEncoder().encode(`${skillMd}\u0000${JSON.stringify(files.map((f) => [f.path, f.encoding ?? "utf8", f.content]))}`); |
| 154 | const hash = await crypto.subtle.digest("SHA-256", data); |
| 155 | return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 156 | } |
| 157 | |
| 158 | /** A version's SKILL.md and files, for `use_skill`. */ |
| 159 | export async function readVersion(db: D1Database, skillId: string, version: number): Promise<StoredVersion | null> { |
| 160 | const row = await db.prepare("SELECT skill_md, files FROM skill_versions WHERE skill_id = ? AND version = ?").bind(skillId, version).first<{ skill_md: string; files: string }>(); |
| 161 | return row ? { skill_md: row.skill_md, files: json<SkillFile[]>(row.files, []) } : null; |
| 162 | } |
| 163 | |
| 164 | async function skillByName(db: D1Database, workspaceId: string, name: string): Promise<SkillRow | null> { |
| 165 | return db.prepare("SELECT * FROM skills WHERE workspace_id = ? AND name = ? AND archived_at IS NULL").bind(workspaceId, name).first<SkillRow>(); |
| 166 | } |
| 167 | |
| 168 | async function attachmentsOf(db: D1Database, skillIds: string[]): Promise<AttachmentRow[]> { |
| 169 | if (!skillIds.length) return []; |
| 170 | const rows = await db |
| 171 | .prepare("SELECT id, skill_id, scope, target, version, attached_by, attached_at FROM skill_attachments WHERE skill_id IN (SELECT value FROM json_each(?)) ORDER BY attached_at") |
| 172 | .bind(JSON.stringify(skillIds)) |
| 173 | .all<AttachmentRow>(); |
| 174 | return rows.results; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Writes `checked` as the skill's next version (a new skill when there is |
| 179 | * none), or publishes a draft in place, and moves the attachments `move` |
| 180 | * picks to it. An unchanged folder writes nothing. Safe against two saves |
| 181 | * at once: the second is told to look again. |
| 182 | */ |
| 183 | export async function writeVersion( |
| 184 | db: D1Database, |
| 185 | input: { |
| 186 | workspaceId: string; |
| 187 | existing: SkillRow | null; |
| 188 | checked: CheckedSkill; |
| 189 | origin: SkillOrigin; |
| 190 | note: string | null; |
| 191 | by: string; |
| 192 | now: Date; |
| 193 | status: SkillStatus; |
| 194 | mirrored: boolean; |
| 195 | move: (attachment: AttachmentRow) => boolean; |
| 196 | }, |
| 197 | ): Promise<Result<{ id: string; version: number; changed: boolean; moved: number }>> { |
| 198 | const { existing, checked, now } = input; |
| 199 | const at = now.toISOString(); |
| 200 | const digest = await digestOf(checked.skill_md, checked.files); |
| 201 | const filesJson = JSON.stringify(checked.files); |
| 202 | const tools = JSON.stringify(checked.tools); |
| 203 | const origin = JSON.stringify(input.origin); |
| 204 | const summary = [checked.name, checked.description, tools, checked.requires_computer ? 1 : 0, checked.files.length, checked.bytes, origin, input.mirrored ? 1 : 0] as const; |
| 205 | |
| 206 | if (!existing) { |
| 207 | const count = await db.prepare("SELECT COUNT(*) AS n FROM skills WHERE workspace_id = ? AND archived_at IS NULL").bind(input.workspaceId).first<{ n: number }>(); |
| 208 | if ((count?.n ?? 0) >= LIBRARY_MAX) return fail("invalid", `A workspace's library holds at most ${LIBRARY_MAX} skills.`); |
| 209 | const id = newId("skl", now.getTime()); |
| 210 | try { |
| 211 | await db.batch([ |
| 212 | db |
| 213 | .prepare( |
| 214 | `INSERT INTO skills (id, workspace_id, name, description, tools, requires_computer, files, bytes, origin, mirrored, status, version, created_by, created_at, updated_by, updated_at) |
| 215 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 1, ?12, ?13, ?12, ?13)`, |
| 216 | ) |
| 217 | .bind(id, input.workspaceId, ...summary, input.status, input.by, at), |
| 218 | versionInsert(db, id, 1, checked, filesJson, origin, input.note, digest, input.by, at), |
| 219 | ]); |
| 220 | } catch (error) { |
| 221 | if (String(error).includes("UNIQUE")) return fail("conflict", `The library already has a skill called ${checked.name}.`); |
| 222 | throw error; |
| 223 | } |
| 224 | return ok({ id, version: 1, changed: true, moved: 0 }); |
| 225 | } |
| 226 | |
| 227 | if (checked.name !== existing.name) { |
| 228 | const taken = await skillByName(db, input.workspaceId, checked.name); |
| 229 | if (taken && taken.id !== existing.id) return fail("conflict", `The library already has a skill called ${checked.name}.`); |
| 230 | } |
| 231 | |
| 232 | // A draft is published in place: it has no history yet. |
| 233 | if (existing.status === "draft") { |
| 234 | const [updated] = await db.batch([ |
| 235 | db |
| 236 | .prepare( |
| 237 | `UPDATE skills SET name = ?1, description = ?2, tools = ?3, requires_computer = ?4, files = ?5, bytes = ?6, origin = ?7, mirrored = ?8, status = ?9, updated_by = ?10, updated_at = ?11 |
| 238 | WHERE id = ?12 AND version = ?13 AND status = 'draft'`, |
| 239 | ) |
| 240 | .bind(...summary, input.status, input.by, at, existing.id, existing.version), |
| 241 | db |
| 242 | .prepare( |
| 243 | `UPDATE skill_versions SET description = ?1, tools = ?2, requires_computer = ?3, skill_md = ?4, files = ?5, bytes = ?6, note = ?7, digest = ?8, created_by = ?9, created_at = ?10 |
| 244 | WHERE skill_id = ?11 AND version = ?12`, |
| 245 | ) |
| 246 | .bind(checked.description, tools, checked.requires_computer ? 1 : 0, checked.skill_md, filesJson, checked.bytes, input.note, digest, input.by, at, existing.id, existing.version), |
| 247 | ]); |
| 248 | if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`); |
| 249 | return ok({ id: existing.id, version: existing.version, changed: true, moved: 0 }); |
| 250 | } |
| 251 | |
| 252 | const latest = await db.prepare("SELECT digest FROM skill_versions WHERE skill_id = ? AND version = ?").bind(existing.id, existing.version).first<{ digest: string }>(); |
| 253 | const attachments = await attachmentsOf(db, [existing.id]); |
| 254 | if (latest?.digest === digest && checked.name === existing.name) { |
| 255 | // Nothing new; a stale attachment may still be moved on request. |
| 256 | const stale = attachments.filter((a) => a.version !== existing.version && input.move(a)); |
| 257 | if (stale.length) await db.prepare("UPDATE skill_attachments SET version = ? WHERE id IN (SELECT value FROM json_each(?))").bind(existing.version, JSON.stringify(stale.map((a) => a.id))).run(); |
| 258 | if (!!existing.mirrored !== input.mirrored) await db.prepare("UPDATE skills SET mirrored = ? WHERE id = ?").bind(input.mirrored ? 1 : 0, existing.id).run(); |
| 259 | return ok({ id: existing.id, version: existing.version, changed: false, moved: stale.length }); |
| 260 | } |
| 261 | const version = existing.version + 1; |
| 262 | const moving = attachments.filter(input.move).map((a) => a.id); |
| 263 | try { |
| 264 | const [updated] = await db.batch([ |
| 265 | db |
| 266 | .prepare( |
| 267 | `UPDATE skills SET name = ?1, description = ?2, tools = ?3, requires_computer = ?4, files = ?5, bytes = ?6, origin = ?7, mirrored = ?8, version = ?9, updated_by = ?10, updated_at = ?11 |
| 268 | WHERE id = ?12 AND version = ?13`, |
| 269 | ) |
| 270 | .bind(...summary, version, input.by, at, existing.id, existing.version), |
| 271 | versionInsert(db, existing.id, version, checked, filesJson, origin, input.note, digest, input.by, at), |
| 272 | db |
| 273 | .prepare("UPDATE skill_attachments SET version = ?1 WHERE id IN (SELECT value FROM json_each(?2)) AND EXISTS (SELECT 1 FROM skills WHERE id = ?3 AND version = ?1)") |
| 274 | .bind(version, JSON.stringify(moving), existing.id), |
| 275 | ]); |
| 276 | if (!updated?.meta?.changes) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`); |
| 277 | } catch (error) { |
| 278 | if (String(error).includes("UNIQUE")) return fail("conflict", `${existing.name} was changed meanwhile. Reload it and try again.`); |
| 279 | throw error; |
| 280 | } |
| 281 | return ok({ id: existing.id, version, changed: true, moved: moving.length }); |
| 282 | } |
| 283 | |
| 284 | function versionInsert(db: D1Database, id: string, version: number, checked: CheckedSkill, files: string, origin: string, note: string | null, digest: string, by: string, at: string): D1PreparedStatement { |
| 285 | return db |
| 286 | .prepare( |
| 287 | `INSERT INTO skill_versions (skill_id, version, description, tools, requires_computer, skill_md, files, bytes, origin, note, digest, created_by, created_at) |
| 288 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`, |
| 289 | ) |
| 290 | .bind(id, version, checked.description, JSON.stringify(checked.tools), checked.requires_computer ? 1 : 0, checked.skill_md, files, checked.bytes, origin, note, digest, by, at); |
| 291 | } |
| 292 | |
| 293 | /** A version note, tidied: one line, at most 200 characters. */ |
| 294 | function cleanNote(note: unknown): string | null { |
| 295 | if (typeof note !== "string") return null; |
| 296 | const line = note.replace(/\s+/g, " ").trim().slice(0, 200); |
| 297 | return line || null; |
| 298 | } |
| 299 | |
| 300 | /** Text from a repository blob, or its bytes as base64. */ |
| 301 | function blobFile(path: string, data: string): SkillFile { |
| 302 | const binary = atob(data); |
| 303 | const bytes = new Uint8Array(binary.length); |
| 304 | for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| 305 | return asSkillFile(path, bytes); |
| 306 | } |
| 307 | |
| 308 | /** A skill's folder read from a repository: every file under `dir` at the listing's commit. */ |
| 309 | async function readRepoFolder( |
| 310 | ports: Pick<LibraryPorts, "blobs">, |
| 311 | repoId: string, |
| 312 | listing: { path: string; hash: string | null }[], |
| 313 | dir: string, |
| 314 | ): Promise<Result<SkillFile[]>> { |
| 315 | const prefix = dir ? `${dir}/` : ""; |
| 316 | const wanted = listing.filter((f): f is { path: string; hash: string } => !!f.hash && f.path.startsWith(prefix)); |
| 317 | if (!wanted.length) return fail("not_found", `There are no files in ${dir || "the repository's top folder"}.`); |
| 318 | if (wanted.length > SKILL_FILES_MAX) return fail("invalid", `A skill holds at most ${SKILL_FILES_MAX} files; ${dir || "that folder"} has ${wanted.length}.`); |
| 319 | const data = new Map<string, string | null>(); |
| 320 | const hashes = [...new Set(wanted.map((f) => f.hash))]; |
| 321 | for (let i = 0; i < hashes.length; i += 100) { |
| 322 | for (const blob of await ports.blobs(repoId, hashes.slice(i, i + 100))) data.set(blob.hash, blob.data); |
| 323 | } |
| 324 | const files: SkillFile[] = []; |
| 325 | let bytes = 0; |
| 326 | for (const file of wanted) { |
| 327 | const content = data.get(file.hash); |
| 328 | if (content == null) return fail("invalid", `${file.path} is too large for a skill (at most 1 MB for the whole folder).`); |
| 329 | const one = blobFile(file.path.slice(prefix.length), content); |
| 330 | bytes += skillFileBytes(one); |
| 331 | if (bytes > SKILL_FOLDER_MAX_BYTES) return fail("invalid", `${dir || "That folder"} is over 1 MB, the most a skill holds.`); |
| 332 | files.push(one); |
| 333 | } |
| 334 | return ok(files); |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Reads the repository a library follows: each `.g1t/skills/<name>/` |
| 339 | * folder becomes or updates the skill of its name (moving its |
| 340 | * attachments), and skills whose folder is gone stop following it. |
| 341 | * Returns what changed and what couldn't be read. |
| 342 | */ |
| 343 | export async function syncMirror( |
| 344 | db: D1Database, |
| 345 | ports: Pick<LibraryPorts, "listFiles" | "blobs">, |
| 346 | mirror: MirrorRow, |
| 347 | now: Date, |
| 348 | ): Promise<{ changed: string[]; problems: string[]; commit: string | null }> { |
| 349 | const changed: string[] = []; |
| 350 | const problems: string[] = []; |
| 351 | let commit: string | null = null; |
| 352 | try { |
| 353 | const listing = await ports.listFiles(mirror.repo_id, null); |
| 354 | commit = listing.commit; |
| 355 | const base = `${SKILLS_REPO_DIR}/`; |
| 356 | const folders = [...new Set(listing.files.filter((f) => f.path.startsWith(base) && f.path.slice(base.length).includes("/")).map((f) => f.path.slice(base.length).split("/")[0]!))].sort(); |
| 357 | if (listing.truncated) problems.push("The repository has more files than g1t reads at once, so some skills may be missing."); |
| 358 | const seen = new Set<string>(); |
| 359 | for (const folder of folders.slice(0, 200)) { |
| 360 | seen.add(folder); |
| 361 | const files = await readRepoFolder(ports, mirror.repo_id, listing.files, `${base}${folder}`); |
| 362 | if (!files.ok) { |
| 363 | problems.push(`${folder}: ${files.error.message}`); |
| 364 | continue; |
| 365 | } |
| 366 | const checked = checkSkillFolder(files.value, { expectName: folder }); |
| 367 | if (!checked.ok) { |
| 368 | problems.push(`${folder}: ${checked.message}`); |
| 369 | continue; |
| 370 | } |
| 371 | const existing = await skillByName(db, mirror.workspace_id, checked.skill.name); |
| 372 | if (existing && !existing.mirrored) { |
| 373 | problems.push(`${folder}: the library already has a skill called ${folder} that isn't from this repository. Rename one of them.`); |
| 374 | continue; |
| 375 | } |
| 376 | const written = await writeVersion(db, { |
| 377 | workspaceId: mirror.workspace_id, |
| 378 | existing, |
| 379 | checked: checked.skill, |
| 380 | origin: { kind: "mirror", repo: mirror.repo, path: `${base}${folder}`, commit: commit ?? "" }, |
| 381 | note: commit ? `From ${mirror.repo} at ${commit.slice(0, 8)}` : null, |
| 382 | by: mirror.linked_by, |
| 383 | now, |
| 384 | status: "published", |
| 385 | mirrored: true, |
| 386 | // The repository's own review is the review: every attachment follows. |
| 387 | move: () => true, |
| 388 | }); |
| 389 | if (!written.ok) problems.push(`${folder}: ${written.error.message}`); |
| 390 | else if (written.value.changed) changed.push(folder); |
| 391 | } |
| 392 | // Gone from the repository: kept in the library, editable here again. |
| 393 | const following = await db.prepare("SELECT name FROM skills WHERE workspace_id = ? AND mirrored = 1 AND archived_at IS NULL").bind(mirror.workspace_id).all<{ name: string }>(); |
| 394 | for (const { name } of following.results) { |
| 395 | if (seen.has(name)) continue; |
| 396 | await db.prepare("UPDATE skills SET mirrored = 0 WHERE workspace_id = ? AND name = ? AND archived_at IS NULL").bind(mirror.workspace_id, name).run(); |
| 397 | problems.push(`${name} is no longer in the repository. It stays in the library, and can be edited here.`); |
| 398 | } |
| 399 | } catch (error) { |
| 400 | console.error("agents: a skills repository wasn't read", mirror.repo, String(error)); |
| 401 | problems.push("The repository couldn't be read just now."); |
| 402 | } |
| 403 | await db |
| 404 | .prepare("UPDATE skill_mirrors SET commit_sha = COALESCE(?, commit_sha), synced_at = ?, error = ? WHERE workspace_id = ?") |
| 405 | .bind(commit, now.toISOString(), problems.length ? problems.join("\n").slice(0, 4000) : null, mirror.workspace_id) |
| 406 | .run(); |
| 407 | return { changed, problems, commit }; |
| 408 | } |
| 409 | |
| 410 | /** After a push to a repository's default branch: every library that follows it is read again. */ |
| 411 | export async function onPush(db: D1Database, ports: Pick<LibraryPorts, "listFiles" | "blobs">, repoId: string, now = new Date()): Promise<number> { |
| 412 | const mirrors = await db.prepare("SELECT * FROM skill_mirrors WHERE repo_id = ?").bind(repoId).all<MirrorRow>(); |
| 413 | for (const mirror of mirrors.results) await syncMirror(db, ports, mirror, now); |
| 414 | return mirrors.results.length; |
| 415 | } |
| 416 | |
| 417 | function mirrorOut(row: MirrorRow | null): SkillMirror | null { |
| 418 | if (!row) return null; |
| 419 | return { repo: row.repo, branch: row.branch, commit: row.commit_sha, synced_at: row.synced_at, error: row.error, linked_by: row.linked_by, linked_at: row.linked_at }; |
| 420 | } |
| 421 | |
| 422 | /** A library skill as its pages show it. */ |
| 423 | function skillOut(row: SkillRow, attachments: AttachmentRow[], actor: Actor, labels: Labels): LibrarySkill { |
| 424 | return { |
| 425 | id: row.id, |
| 426 | name: row.name, |
| 427 | description: row.description, |
| 428 | status: row.status, |
| 429 | version: row.version, |
| 430 | tools: json<string[]>(row.tools, []), |
| 431 | requires_computer: !!row.requires_computer, |
| 432 | files: row.files, |
| 433 | bytes: row.bytes, |
| 434 | origin: json<SkillOrigin>(row.origin, { kind: "written" }), |
| 435 | mirrored: !!row.mirrored, |
| 436 | attachments: attachments.map((a) => attachmentOut(a, actor, labels)), |
| 437 | created_by: row.created_by, |
| 438 | created_at: row.created_at, |
| 439 | updated_by: row.updated_by, |
| 440 | updated_at: row.updated_at, |
| 441 | can_edit: mayEdit(actor, row), |
| 442 | can_delete: mayDelete(actor, row, attachments), |
| 443 | }; |
| 444 | } |
| 445 | |
| 446 | type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> }; |
| 447 | |
| 448 | function attachmentOut(a: AttachmentRow, actor: Actor, labels: Labels): SkillAttachment { |
| 449 | const agent = a.scope === "agent" ? labels.agents.get(a.target) : null; |
| 450 | const target = a.scope === "agent" ? (agent?.handle ?? null) : a.scope === "team" ? a.target : null; |
| 451 | const label = a.scope === "workspace" ? "Every agent" : a.scope === "agent" ? (agent ? `@${agent.handle}` : "An archived agent") : (labels.teams.get(a.target) ?? a.target); |
| 452 | return { id: a.id, scope: a.scope, target, label, version: a.version, attached_by: a.attached_by, attached_at: a.attached_at, can_change: mayChange(actor, a.scope, a.target) }; |
| 453 | } |
| 454 | |
| 455 | /** One workspace's library, as one viewer may use it. */ |
| 456 | export class Library { |
| 457 | private readonly ctx: LibraryContext; |
| 458 | private teamList: { slug: string; name: string; can_manage: boolean }[] | null = null; |
| 459 | |
| 460 | constructor(ctx: LibraryContext) { |
| 461 | this.ctx = ctx; |
| 462 | } |
| 463 | |
| 464 | private get db(): D1Database { |
| 465 | return this.ctx.db; |
| 466 | } |
| 467 | |
| 468 | private now(): Date { |
| 469 | return this.ctx.now ?? new Date(); |
| 470 | } |
| 471 | |
| 472 | private async teams(): Promise<{ slug: string; name: string; can_manage: boolean }[]> { |
| 473 | this.teamList ??= (await this.ctx.ports.teams().catch(() => null)) ?? []; |
| 474 | return this.teamList; |
| 475 | } |
| 476 | |
| 477 | async actor(): Promise<Actor> { |
| 478 | const teams = this.ctx.viewer.kind === "agent" ? [] : await this.teams(); |
| 479 | return { username: this.ctx.viewer.username, owner: this.ctx.owner, maintains: new Set(teams.filter((t) => this.ctx.owner || t.can_manage).map((t) => t.slug)) }; |
| 480 | } |
| 481 | |
| 482 | private async labels(): Promise<Labels> { |
| 483 | const [agents, teams] = await Promise.all([ |
| 484 | this.db.prepare("SELECT id, handle, display_name FROM agents WHERE workspace_id = ? AND archived_at IS NULL ORDER BY builtin DESC, handle").bind(this.ctx.workspaceId).all<{ id: string; handle: string; display_name: string }>(), |
| 485 | this.teams(), |
| 486 | ]); |
| 487 | return { agents: new Map(agents.results.map((a) => [a.id, a])), teams: new Map(teams.map((t) => [t.slug, t.name])) }; |
| 488 | } |
| 489 | |
| 490 | private audit(action: string, name: string, message: string): void { |
| 491 | try { |
| 492 | this.ctx.ports.audit(action, name, message); |
| 493 | } catch { |
| 494 | // The log never fails the change. |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | async library(): Promise<Result<SkillLibrary>> { |
| 499 | const [rows, mirror, actor, labels] = await Promise.all([ |
| 500 | this.db.prepare("SELECT * FROM skills WHERE workspace_id = ? AND archived_at IS NULL ORDER BY status = 'draft' DESC, name LIMIT ?").bind(this.ctx.workspaceId, LIBRARY_MAX).all<SkillRow>(), |
| 501 | this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>(), |
| 502 | this.actor(), |
| 503 | this.labels(), |
| 504 | ]); |
| 505 | const attachments = await attachmentsOf(this.db, rows.results.map((r) => r.id)); |
| 506 | const teams = await this.teams(); |
| 507 | return ok({ |
| 508 | skills: rows.results.map((row) => skillOut(row, attachments.filter((a) => a.skill_id === row.id), actor, labels)), |
| 509 | mirror: mirrorOut(mirror), |
| 510 | can_write: mayWrite(actor), |
| 511 | can_manage: actor.owner, |
| 512 | teams: teams.filter((t) => actor.maintains.has(t.slug)).map((t) => ({ slug: t.slug, name: t.name })), |
| 513 | agents: actor.owner ? [...labels.agents.values()].map((a) => ({ handle: a.handle, display_name: a.display_name })) : [], |
| 514 | }); |
| 515 | } |
| 516 | |
| 517 | private async named(name: unknown): Promise<Result<SkillRow>> { |
| 518 | const key = String(name ?? "").trim().toLowerCase(); |
| 519 | const row = key ? await skillByName(this.db, this.ctx.workspaceId, key) : null; |
| 520 | if (row) return ok(row); |
| 521 | if (FOUNDATIONAL_SKILLS.some((s) => s.id === key)) return fail("not_found", `${key} is one of g1t's foundational skills: see it on any agent's Skills tab.`); |
| 522 | return fail("not_found", `The library has no skill called ${key || "that"}.`); |
| 523 | } |
| 524 | |
| 525 | async detail(name: unknown, version?: unknown): Promise<Result<SkillDetail>> { |
| 526 | const found = await this.named(name); |
| 527 | if (!found.ok) return found; |
| 528 | const row = found.value; |
| 529 | const shown = version == null || version === "" ? row.version : Math.floor(Number(version)); |
| 530 | const [stored, versions, attachments, actor, labels] = await Promise.all([ |
| 531 | this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, shown).first<VersionRow>(), |
| 532 | this.db |
| 533 | .prepare("SELECT version, description, note, origin, bytes, json_array_length(files) AS files, created_by, created_at FROM skill_versions WHERE skill_id = ? ORDER BY version DESC LIMIT 200") |
| 534 | .bind(row.id) |
| 535 | .all<{ version: number; description: string; note: string | null; origin: string; bytes: number; files: number; created_by: string; created_at: string }>(), |
| 536 | attachmentsOf(this.db, [row.id]), |
| 537 | this.actor(), |
| 538 | this.labels(), |
| 539 | ]); |
| 540 | if (!stored) return fail("not_found", `${row.name} has no version ${shown}.`); |
| 541 | const split = splitFrontMatter(stored.skill_md); |
| 542 | let extra: Record<string, unknown> = {}; |
| 543 | if (split.ok) { |
| 544 | try { |
| 545 | const front = parseFrontMatter(split.yaml); |
| 546 | for (const [key, value] of Object.entries(front)) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value; |
| 547 | } catch { |
| 548 | extra = {}; |
| 549 | } |
| 550 | } |
| 551 | const files: SkillFileEntry[] = json<SkillFile[]>(stored.files, []).map((f) => { |
| 552 | const bytes = skillFileBytes(f); |
| 553 | return { path: f.path, bytes, encoding: f.encoding === "base64" ? "base64" : "utf8", content: f.encoding !== "base64" && bytes <= SHOWN_FILE_BYTES ? f.content : null, script: f.path.startsWith("scripts/") }; |
| 554 | }); |
| 555 | const history: SkillVersionEntry[] = versions.results.map((v) => ({ |
| 556 | version: v.version, |
| 557 | description: v.description, |
| 558 | note: v.note, |
| 559 | origin: json<SkillOrigin>(v.origin, { kind: "written" }), |
| 560 | bytes: v.bytes, |
| 561 | files: v.files ?? 0, |
| 562 | created_by: v.created_by, |
| 563 | created_at: v.created_at, |
| 564 | })); |
| 565 | return ok({ |
| 566 | skill: skillOut(row, attachments, actor, labels), |
| 567 | shown, |
| 568 | skill_md: stored.skill_md, |
| 569 | instructions: split.ok ? split.body.trim() : stored.skill_md, |
| 570 | tools: json<string[]>(stored.tools, []), |
| 571 | requires_computer: !!stored.requires_computer, |
| 572 | extra, |
| 573 | files, |
| 574 | versions: history, |
| 575 | }); |
| 576 | } |
| 577 | |
| 578 | /** Writes a skill from the editor: a new one, a new version, or a draft published. */ |
| 579 | async save(name: unknown, input: SkillInput): Promise<Result<SkillDetail>> { |
| 580 | const actor = await this.actor(); |
| 581 | if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers write skills."); |
| 582 | if (!input || typeof input !== "object") return fail("invalid", "Say what the skill is."); |
| 583 | let existing: SkillRow | null = null; |
| 584 | let prior: StoredVersion | null = null; |
| 585 | if (name != null && name !== "") { |
| 586 | const found = await this.named(name); |
| 587 | if (!found.ok) return found; |
| 588 | existing = found.value; |
| 589 | if (existing.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`); |
| 590 | if (!mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote."); |
| 591 | prior = await readVersion(this.db, existing.id, existing.version); |
| 592 | } |
| 593 | const skillName = String(input.name ?? "").trim().toLowerCase(); |
| 594 | const problem = skillNameProblem(skillName); |
| 595 | if (problem) return fail("invalid", problem); |
| 596 | const description = String(input.description ?? "").trim(); |
| 597 | const instructions = String(input.instructions ?? "").trim(); |
| 598 | const tools = Array.isArray(input.tools) ? input.tools.filter((t): t is string => typeof t === "string") : []; |
| 599 | let extra: Record<string, unknown> = {}; |
| 600 | if (prior) { |
| 601 | const split = splitFrontMatter(prior.skill_md); |
| 602 | try { |
| 603 | if (split.ok) for (const [key, value] of Object.entries(parseFrontMatter(split.yaml))) if (!["name", "description", "tools", "requires_computer"].includes(key)) extra[key] = value; |
| 604 | } catch { |
| 605 | extra = {}; |
| 606 | } |
| 607 | } |
| 608 | // The current version's files, less those removed, with those added (by path). |
| 609 | const removed = new Set(Array.isArray(input.remove_files) ? input.remove_files.filter((p): p is string => typeof p === "string") : []); |
| 610 | const added = Array.isArray(input.add_files) ? input.add_files.filter((f): f is SkillFile => !!f && typeof f.path === "string" && typeof f.content === "string") : []; |
| 611 | const addedPaths = new Set(added.map((f) => f.path.replace(/^\.\//, ""))); |
| 612 | const files = [...(prior?.files ?? []).filter((f) => !removed.has(f.path) && !addedPaths.has(f.path)), ...added]; |
| 613 | const skillMd = renderSkillMd({ name: skillName, description, tools, requires_computer: input.requires_computer === true, body: instructions, extra }); |
| 614 | const checked = checkSkillFolder([{ path: "SKILL.md", content: skillMd }, ...files.filter((f) => f?.path !== "SKILL.md")]); |
| 615 | if (!checked.ok) return fail("invalid", checked.message); |
| 616 | const publishing = existing?.status === "draft"; |
| 617 | const updateAll = input.update_attachments !== false; |
| 618 | const written = await writeVersion(this.db, { |
| 619 | workspaceId: this.ctx.workspaceId, |
| 620 | existing, |
| 621 | checked: checked.skill, |
| 622 | origin: existing && publishing ? json<SkillOrigin>(existing.origin, { kind: "written" }) : { kind: "written" }, |
| 623 | note: cleanNote(input.note), |
| 624 | by: actor.username, |
| 625 | now: this.now(), |
| 626 | status: "published", |
| 627 | mirrored: false, |
| 628 | move: (a) => updateAll && mayChange(actor, a.scope, a.target), |
| 629 | }); |
| 630 | if (!written.ok) return written; |
| 631 | const verb = !existing ? "Wrote" : publishing ? "Published" : written.value.changed ? "Changed" : "Saved"; |
| 632 | if (written.value.changed || !existing) this.audit(publishing ? "publish_skill" : existing ? "update_skill" : "create_skill", skillName, `${verb} the skill ${skillName} (version ${written.value.version})`); |
| 633 | return this.detail(skillName); |
| 634 | } |
| 635 | |
| 636 | /** Imports a skill from an upload or a repository folder. */ |
| 637 | async import(source: SkillImport, replace: boolean): Promise<Result<SkillDetail>> { |
| 638 | const actor = await this.actor(); |
| 639 | if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers import skills."); |
| 640 | let files: SkillFile[]; |
| 641 | let origin: SkillOrigin; |
| 642 | if (source?.kind === "upload") { |
| 643 | const filename = String(source.filename ?? "").slice(0, 200); |
| 644 | const read = await readUpload(filename, String(source.data_base64 ?? "")); |
| 645 | if (!read.ok) return fail("invalid", read.message); |
| 646 | files = read.files; |
| 647 | origin = { kind: "upload", filename: filename || "SKILL.md" }; |
| 648 | } else if (source?.kind === "repository") { |
| 649 | const full = String(source.repo ?? "").trim().replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); |
| 650 | if (!/^[^/\s]+\/[^/\s]+$/.test(full)) return fail("invalid", "Name the repository as workspace/name."); |
| 651 | const repo = await this.ctx.ports.repo(full); |
| 652 | if (!repo) return fail("not_found", `There is no repository ${full} you can read.`); |
| 653 | let dir = String(source.path ?? "").trim().replace(/^\/+|\/+$/g, ""); |
| 654 | if (/(^|\/)SKILL\.md$/i.test(dir)) dir = dir.replace(/\/?SKILL\.md$/i, ""); |
| 655 | const ref = String(source.ref ?? "").trim() || repo.default_branch; |
| 656 | const listing = await this.ctx.ports.listFiles(repo.id, ref).catch(() => null); |
| 657 | if (!listing?.commit) return fail("not_found", `${full} has no branch, tag or commit called ${ref}.`); |
| 658 | const read = await readRepoFolder(this.ctx.ports, repo.id, listing.files, dir); |
| 659 | if (!read.ok) return read; |
| 660 | files = read.value; |
| 661 | origin = { kind: "repository", repo: repo.full, path: dir || ".", ref, commit: listing.commit }; |
| 662 | } else return fail("invalid", "Import from an upload or a repository folder."); |
| 663 | const checked = checkSkillFolder(files); |
| 664 | if (!checked.ok) return fail("invalid", checked.message); |
| 665 | const existing = await skillByName(this.db, this.ctx.workspaceId, checked.skill.name); |
| 666 | if (existing && !replace) { |
| 667 | return fail("conflict", `The library already has a skill called ${checked.skill.name}. Import it as a new version of ${checked.skill.name}, or change the name in its SKILL.md.`); |
| 668 | } |
| 669 | if (existing?.mirrored) return fail("invalid", `${existing.name} follows the repository: change it there, in ${SKILLS_REPO_DIR}/${existing.name}/.`); |
| 670 | if (existing && !mayEdit(actor, existing)) return fail("forbidden", "Owners edit any skill; team maintainers edit the skills they wrote."); |
| 671 | const written = await writeVersion(this.db, { |
| 672 | workspaceId: this.ctx.workspaceId, |
| 673 | existing, |
| 674 | checked: checked.skill, |
| 675 | origin, |
| 676 | note: origin.kind === "repository" ? `Imported from ${origin.repo} at ${origin.commit.slice(0, 8)}` : `Imported from ${origin.kind === "upload" ? origin.filename : "an upload"}`, |
| 677 | by: actor.username, |
| 678 | now: this.now(), |
| 679 | status: "published", |
| 680 | mirrored: false, |
| 681 | move: (a) => mayChange(actor, a.scope, a.target), |
| 682 | }); |
| 683 | if (!written.ok) return written; |
| 684 | this.audit("import_skill", checked.skill.name, `Imported the skill ${checked.skill.name} (version ${written.value.version})`); |
| 685 | return this.detail(checked.skill.name); |
| 686 | } |
| 687 | |
| 688 | /** Saves a draft an agent wrote from a session; a person publishes it after reviewing it. */ |
| 689 | async saveDraft(checked: CheckedSkill, origin: Extract<SkillOrigin, { kind: "session" }>): Promise<Result<SkillDetail>> { |
| 690 | let name = checked.name; |
| 691 | for (let n = 2; await skillByName(this.db, this.ctx.workspaceId, name); n++) { |
| 692 | name = `${checked.name.slice(0, 60)}-${n}`; |
| 693 | if (n > 50) return fail("conflict", "Too many skills share that name."); |
| 694 | } |
| 695 | const renamed = name === checked.name ? checked : { ...checked, name, skill_md: checked.skill_md.replace(/^name:.*$/m, `name: ${name}`) }; |
| 696 | const written = await writeVersion(this.db, { |
| 697 | workspaceId: this.ctx.workspaceId, |
| 698 | existing: null, |
| 699 | checked: renamed, |
| 700 | origin, |
| 701 | note: `Drafted by @${origin.agent} from the session "${origin.title}"`, |
| 702 | by: this.ctx.viewer.username, |
| 703 | now: this.now(), |
| 704 | status: "draft", |
| 705 | mirrored: false, |
| 706 | move: () => false, |
| 707 | }); |
| 708 | if (!written.ok) return written; |
| 709 | this.audit("draft_skill", name, `Saved a draft skill ${name} from a session of @${origin.agent}`); |
| 710 | return this.detail(name); |
| 711 | } |
| 712 | |
| 713 | async attach(name: unknown, scope: unknown, target: unknown): Promise<Result<SkillDetail>> { |
| 714 | const found = await this.named(name); |
| 715 | if (!found.ok) return found; |
| 716 | const row = found.value; |
| 717 | if (row.status === "draft") return fail("invalid", "Publish the draft before attaching it."); |
| 718 | if (scope !== "agent" && scope !== "team" && scope !== "workspace") return fail("invalid", "Attach a skill to an agent, a team or the whole workspace."); |
| 719 | const actor = await this.actor(); |
| 720 | let key = ""; |
| 721 | let label = "every agent"; |
| 722 | if (scope === "agent") { |
| 723 | const handle = String(target ?? "").trim().replace(/^@/, "").toLowerCase(); |
| 724 | const agent = await this.db.prepare("SELECT id, handle FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(this.ctx.workspaceId, handle).first<{ id: string; handle: string }>(); |
| 725 | if (!agent) return fail("not_found", `There is no agent called @${handle}.`); |
| 726 | key = agent.id; |
| 727 | label = `@${agent.handle}`; |
| 728 | } else if (scope === "team") { |
| 729 | const slug = String(target ?? "").trim().toLowerCase(); |
| 730 | const team = (await this.teams()).find((t) => t.slug === slug); |
| 731 | if (!team) return fail("not_found", `${this.ctx.slug} has no team called ${slug}.`); |
| 732 | key = team.slug; |
| 733 | label = team.name; |
| 734 | } |
| 735 | if (!mayChange(actor, scope, key)) { |
| 736 | return fail("forbidden", scope === "team" ? "Only owners and the team's maintainers attach skills to it." : "Only the workspace's owners attach skills to agents and to every agent."); |
| 737 | } |
| 738 | // At most SKILLS_PER_AGENT_MAX reach any one agent, through the teams it is on (team |
| 739 | // memberships, from identity): counted for each agent this attachment reaches. |
| 740 | const index = await this.ctx.ports.teamAgentIndex().catch(() => []); |
| 741 | const memberships = JSON.stringify(index.flatMap((team) => team.agent_ids.map((id) => [id, team.slug]))); |
| 742 | const which = scope === "workspace" ? "?3 = ?3" : scope === "team" ? "EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = ?3)" : "ag.id = ?3"; |
| 743 | const most = await this.db |
| 744 | .prepare( |
| 745 | `WITH m(agent_id, slug) AS (SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]') FROM json_each(?4)) |
| 746 | SELECT COALESCE(MAX(n), 0) AS n FROM ( |
| 747 | SELECT ag.id, COUNT(DISTINCT a.skill_id) AS n |
| 748 | FROM agents ag |
| 749 | JOIN skill_attachments a ON a.workspace_id = ag.workspace_id |
| 750 | AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ag.id) OR (a.scope = 'team' AND EXISTS (SELECT 1 FROM m WHERE m.agent_id = ag.id AND m.slug = a.target))) |
| 751 | JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL |
| 752 | WHERE ag.workspace_id = ?1 AND ag.archived_at IS NULL AND a.skill_id <> ?2 AND ${which} |
| 753 | GROUP BY ag.id)`, |
| 754 | ) |
| 755 | .bind(this.ctx.workspaceId, row.id, key, memberships) |
| 756 | .first<{ n: number }>(); |
| 757 | // And what is attached where it lands already, for a team or workspace with no agents yet. |
| 758 | const reach = |
| 759 | scope === "workspace" ? "(a.scope = 'workspace' AND ?3 = ?3)" : scope === "team" ? "(a.scope = 'workspace' OR (a.scope = 'team' AND a.target = ?3))" : "(a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?3))"; |
| 760 | const count = await this.db |
| 761 | .prepare(`SELECT COUNT(DISTINCT a.skill_id) AS n FROM skill_attachments a JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL WHERE a.workspace_id = ?1 AND a.skill_id <> ?2 AND ${reach}`) |
| 762 | .bind(this.ctx.workspaceId, row.id, key) |
| 763 | .first<{ n: number }>(); |
| 764 | if (Math.max(count?.n ?? 0, most?.n ?? 0) >= SKILLS_PER_AGENT_MAX) { |
| 765 | return fail("invalid", `An agent has at most ${SKILLS_PER_AGENT_MAX} skills from the library, and ${scope === "workspace" ? "an agent" : label} would have more. Detach one first.`); |
| 766 | } |
| 767 | const inserted = await this.db |
| 768 | .prepare( |
| 769 | `INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
| 770 | ON CONFLICT (skill_id, scope, target) DO NOTHING`, |
| 771 | ) |
| 772 | .bind(newId("ska"), this.ctx.workspaceId, row.id, scope, key, row.version, actor.username, this.now().toISOString()) |
| 773 | .run(); |
| 774 | if (!inserted.meta?.changes) return fail("conflict", `${row.name} is already attached to ${label}.`); |
| 775 | this.audit("attach_skill", row.name, `Attached the skill ${row.name} (version ${row.version}) to ${label}`); |
| 776 | return this.detail(row.name); |
| 777 | } |
| 778 | |
| 779 | private async attachment(name: unknown, id: unknown): Promise<Result<{ row: SkillRow; attachment: AttachmentRow; actor: Actor }>> { |
| 780 | const found = await this.named(name); |
| 781 | if (!found.ok) return found; |
| 782 | const attachment = await this.db |
| 783 | .prepare("SELECT id, skill_id, scope, target, version, attached_by, attached_at FROM skill_attachments WHERE id = ? AND skill_id = ?") |
| 784 | .bind(String(id ?? ""), found.value.id) |
| 785 | .first<AttachmentRow>(); |
| 786 | if (!attachment) return fail("not_found", `${found.value.name} isn't attached there.`); |
| 787 | const actor = await this.actor(); |
| 788 | if (!mayChange(actor, attachment.scope, attachment.target)) { |
| 789 | return fail("forbidden", attachment.scope === "team" ? "Only owners and the team's maintainers change what is attached to it." : "Only the workspace's owners change this attachment."); |
| 790 | } |
| 791 | return ok({ row: found.value, attachment, actor }); |
| 792 | } |
| 793 | |
| 794 | async detach(name: unknown, id: unknown): Promise<Result<SkillDetail>> { |
| 795 | const found = await this.attachment(name, id); |
| 796 | if (!found.ok) return found; |
| 797 | await this.db.prepare("DELETE FROM skill_attachments WHERE id = ?").bind(found.value.attachment.id).run(); |
| 798 | this.audit("detach_skill", found.value.row.name, `Detached the skill ${found.value.row.name} (${found.value.attachment.scope})`); |
| 799 | return this.detail(found.value.row.name); |
| 800 | } |
| 801 | |
| 802 | async pin(name: unknown, id: unknown, version: unknown): Promise<Result<SkillDetail>> { |
| 803 | const found = await this.attachment(name, id); |
| 804 | if (!found.ok) return found; |
| 805 | const { row, attachment } = found.value; |
| 806 | const to = version == null ? row.version : Math.floor(Number(version)); |
| 807 | const exists = await this.db.prepare("SELECT 1 AS one FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, to).first(); |
| 808 | if (!exists) return fail("not_found", `${row.name} has no version ${to}.`); |
| 809 | if (to !== attachment.version) { |
| 810 | await this.db.prepare("UPDATE skill_attachments SET version = ? WHERE id = ?").bind(to, attachment.id).run(); |
| 811 | this.audit("pin_skill", row.name, `Moved the skill ${row.name} from version ${attachment.version} to ${to} (${attachment.scope})`); |
| 812 | } |
| 813 | return this.detail(row.name); |
| 814 | } |
| 815 | |
| 816 | async remove(name: unknown): Promise<Result<null>> { |
| 817 | const found = await this.named(name); |
| 818 | if (!found.ok) return found; |
| 819 | const row = found.value; |
| 820 | const [actor, attachments] = await Promise.all([this.actor(), attachmentsOf(this.db, [row.id])]); |
| 821 | if (!mayDelete(actor, row, attachments)) { |
| 822 | return fail("forbidden", row.status === "draft" ? "Only whoever saved the draft, owners and team maintainers discard it." : "Owners delete any skill; team maintainers delete the skills they wrote that only their teams use."); |
| 823 | } |
| 824 | const at = this.now().toISOString(); |
| 825 | await this.db.batch([ |
| 826 | this.db.prepare("UPDATE skills SET archived_at = ?, mirrored = 0 WHERE id = ? AND archived_at IS NULL").bind(at, row.id), |
| 827 | this.db.prepare("DELETE FROM skill_attachments WHERE skill_id = ?").bind(row.id), |
| 828 | ]); |
| 829 | this.audit(row.status === "draft" ? "discard_skill" : "delete_skill", row.name, `${row.status === "draft" ? "Discarded the draft" : "Deleted the skill"} ${row.name}`); |
| 830 | return ok(null); |
| 831 | } |
| 832 | |
| 833 | /** An agent's skills: g1t's foundational ones and the library's that reach it, each once, on or off. */ |
| 834 | async agentSkills(handle: unknown): Promise<Result<AgentSkills>> { |
| 835 | const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase(); |
| 836 | const agent = await this.db |
| 837 | .prepare("SELECT id, handle, skills_off FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL") |
| 838 | .bind(this.ctx.workspaceId, key) |
| 839 | .first<{ id: string; handle: string; skills_off: string | null }>(); |
| 840 | if (!agent) return fail("not_found", `There is no agent called @${key}.`); |
| 841 | const off = new Set(json<string[]>(agent.skills_off, [])); |
| 842 | const [teams, actor] = await Promise.all([this.ctx.ports.agentTeams(agent.id).catch(() => []), this.actor()]); |
| 843 | const teamNames = new Map(teams.map((t) => [t.slug, t.name])); |
| 844 | const rows = await this.db |
| 845 | .prepare( |
| 846 | `SELECT s.id AS skill_id, s.name, s.version AS latest, v.description, a.id AS attachment_id, a.version, v.tools, v.requires_computer, a.scope, a.target, a.attached_at |
| 847 | FROM skill_attachments a |
| 848 | JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL AND s.status = 'published' |
| 849 | JOIN skill_versions v ON v.skill_id = a.skill_id AND v.version = a.version |
| 850 | WHERE a.workspace_id = ?1 |
| 851 | AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?2) OR (a.scope = 'team' AND a.target IN (SELECT value FROM json_each(?3)))) |
| 852 | LIMIT 500`, |
| 853 | ) |
| 854 | .bind(this.ctx.workspaceId, agent.id, JSON.stringify(teams.map((t) => t.slug))) |
| 855 | .all<{ skill_id: string; name: string; latest: number; description: string; attachment_id: string; version: number; tools: string; requires_computer: number; scope: SkillScope; target: string; attached_at: string }>(); |
| 856 | const order: Record<SkillScope, number> = { agent: 0, team: 1, workspace: 2 }; |
| 857 | const seen = new Set<string>(); |
| 858 | const library: AgentSkillLine[] = []; |
| 859 | for (const r of [...rows.results].sort((a, b) => order[a.scope] - order[b.scope] || a.attached_at.localeCompare(b.attached_at))) { |
| 860 | if (seen.has(r.skill_id)) continue; |
| 861 | seen.add(r.skill_id); |
| 862 | library.push({ |
| 863 | id: r.skill_id, |
| 864 | name: r.name, |
| 865 | description: r.description, |
| 866 | foundational: false, |
| 867 | on: !off.has(r.skill_id), |
| 868 | via: r.scope, |
| 869 | via_label: r.scope === "workspace" ? "Every agent" : r.scope === "agent" ? "This agent" : (teamNames.get(r.target) ?? r.target), |
| 870 | attachment_id: r.attachment_id, |
| 871 | version: String(r.version), |
| 872 | update: r.latest > r.version ? r.latest : null, |
| 873 | requires_computer: !!r.requires_computer, |
| 874 | tools: json<string[]>(r.tools, []), |
| 875 | can_change: mayChange(actor, r.scope, r.target), |
| 876 | }); |
| 877 | } |
| 878 | const foundational: AgentSkillLine[] = FOUNDATIONAL_SKILLS.map((s) => ({ |
| 879 | id: s.id, |
| 880 | name: s.name, |
| 881 | description: s.description, |
| 882 | foundational: true, |
| 883 | on: !off.has(s.id), |
| 884 | via: null, |
| 885 | via_label: null, |
| 886 | attachment_id: null, |
| 887 | version: FOUNDATIONAL_SKILLS_VERSION, |
| 888 | update: null, |
| 889 | requires_computer: false, |
| 890 | tools: [...new Set(s.abilities.filter((a) => a.status === "ready").flatMap((a) => a.tools))], |
| 891 | can_change: false, |
| 892 | })); |
| 893 | const onLibrary = library.filter((l) => l.on); |
| 894 | return ok({ |
| 895 | handle: agent.handle, |
| 896 | skills: [...foundational, ...library.sort((a, b) => a.name.localeCompare(b.name))], |
| 897 | over_limit: Math.max(0, onLibrary.length - SKILLS_PER_AGENT_MAX), |
| 898 | }); |
| 899 | } |
| 900 | |
| 901 | /** Links the repository the library follows, or unlinks it; then reads it. Owners only. */ |
| 902 | async setMirror(repo: unknown): Promise<Result<{ mirror: SkillMirror | null; changed: string[]; problems: string[] }>> { |
| 903 | if (!this.ctx.owner) return fail("forbidden", "Only the workspace's owners link a repository to the library."); |
| 904 | if (repo == null || repo === "") { |
| 905 | await this.db.batch([ |
| 906 | this.db.prepare("DELETE FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId), |
| 907 | this.db.prepare("UPDATE skills SET mirrored = 0 WHERE workspace_id = ? AND mirrored = 1").bind(this.ctx.workspaceId), |
| 908 | ]); |
| 909 | this.audit("unlink_skills_repository", "repository", "Stopped following a repository for skills"); |
| 910 | return ok({ mirror: null, changed: [], problems: [] }); |
| 911 | } |
| 912 | const full = String(repo).trim().replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); |
| 913 | if (!/^[^/\s]+\/[^/\s]+$/.test(full)) return fail("invalid", "Name the repository as workspace/name."); |
| 914 | const found = await this.ctx.ports.repo(full); |
| 915 | if (!found) return fail("not_found", `There is no repository ${full} you can read.`); |
| 916 | const at = this.now().toISOString(); |
| 917 | await this.db |
| 918 | .prepare( |
| 919 | `INSERT INTO skill_mirrors (workspace_id, repo_id, repo, branch, linked_by, linked_at) VALUES (?, ?, ?, ?, ?, ?) |
| 920 | ON CONFLICT (workspace_id) DO UPDATE SET repo_id = excluded.repo_id, repo = excluded.repo, branch = excluded.branch, linked_by = excluded.linked_by, linked_at = excluded.linked_at, commit_sha = NULL, synced_at = NULL, error = NULL`, |
| 921 | ) |
| 922 | .bind(this.ctx.workspaceId, found.id, found.full, found.default_branch, this.ctx.viewer.username, at) |
| 923 | .run(); |
| 924 | this.audit("link_skills_repository", "repository", `Follows ${found.full} for skills`); |
| 925 | return this.sync(); |
| 926 | } |
| 927 | |
| 928 | async sync(): Promise<Result<{ mirror: SkillMirror | null; changed: string[]; problems: string[] }>> { |
| 929 | const actor = await this.actor(); |
| 930 | if (!mayWrite(actor)) return fail("forbidden", "Only the workspace's owners and team maintainers read the repository again."); |
| 931 | const mirror = await this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>(); |
| 932 | if (!mirror) return fail("not_found", "The library doesn't follow a repository."); |
| 933 | const result = await syncMirror(this.db, this.ctx.ports, mirror, this.now()); |
| 934 | const after = await this.db.prepare("SELECT * FROM skill_mirrors WHERE workspace_id = ?").bind(this.ctx.workspaceId).first<MirrorRow>(); |
| 935 | return ok({ mirror: mirrorOut(after), changed: result.changed, problems: result.problems }); |
| 936 | } |
| 937 | } |