UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

415 lines (414 loc) • 14.3 kB
import { readFileSync, readdirSync, statSync } from "node:fs"; import { isAbsolute, join, relative } from "node:path"; import { physicalPathOf } from "./physical-path.js"; import { assertNoRetiredKeys } from "./retired-keys.js"; import { thrownText } from "./thrown-value.js"; const SKILL_FILE = "SKILL.md"; const RESOURCE_DIRS = ["assets", "references", "scripts"]; const NAME_MAX_CHARS = 64; const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const DESCRIPTION_MAX_CHARS = 1024; const DEFAULT_MAX_ATTACHMENT_BYTES = 256 * 1024; const FENCE = "---"; function parseSkillFile(text) { const fields = new Map(); const normalized = text.replace(/\r\n/g, "\n"); const lines = normalized.split("\n"); if (lines[0]?.trim() !== FENCE) return { fields, body: normalized, hadFrontmatter: false }; let end = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === FENCE) { end = i; break; } } if (end === -1) return { fields, body: normalized, hadFrontmatter: false }; let i = 1; while (i < end) { const line = lines[i]; const trimmed = line.trim(); if (trimmed === "" || trimmed.startsWith("#")) { i++; continue; } if (/^\s/.test(line)) { i++; continue; } const kv = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed); if (!kv) { i++; continue; } const [, key, raw] = kv; const rawValue = raw.trim(); const block = parseBlockScalarHeader(rawValue); if (block !== undefined) { const read = readBlockScalar(lines, i + 1, end, block); i = read.next; if (!fields.has(key)) fields.set(key, read.value); continue; } if (rawValue.startsWith("[") && !isFlowSequenceClosed(rawValue)) { const joined = readFlowSequenceContinuation(lines, i + 1, end, rawValue); i = joined.next; if (!fields.has(key)) fields.set(key, joined.value); continue; } i++; if (fields.has(key)) continue; fields.set(key, unquote(rawValue)); } let body = lines.slice(end + 1).join("\n"); if (body.startsWith("\n")) body = body.slice(1); return { fields, body, hadFrontmatter: true }; } function parseBlockScalarHeader(value) { const m = /^([|>])([0-9+-]*)$/.exec(value); if (!m) return undefined; const rest = m[2]; if (!/^(?:[1-9]?[+-]?|[+-][1-9]?)$/.test(rest)) return undefined; const digit = /[1-9]/.exec(rest)?.[0]; return { style: m[1] === "|" ? "literal" : "folded", ...(digit !== undefined ? { indent: Number(digit) } : {}) }; } function readBlockScalar(lines, start, end, header) { const indentOf = (s) => /^[ \t]*/.exec(s)[0].length; let indent = header.indent; if (indent === undefined) { for (let j = start; j < end; j++) { if (lines[j].trim() === "") continue; indent = indentOf(lines[j]); break; } } if (indent === undefined || indent === 0) return { value: "", next: start }; const taken = []; let j = start; for (; j < end; j++) { const line = lines[j]; if (line.trim() === "") { taken.push(""); continue; } if (indentOf(line) < indent) break; taken.push(line.slice(indent)); } while (taken.length > 0 && taken[taken.length - 1] === "") taken.pop(); if (header.style === "literal") return { value: taken.join("\n"), next: j }; let folded = ""; let blanks = 0; let started = false; let prevMoreIndented = false; for (const cur of taken) { if (cur === "") { blanks++; continue; } const moreIndented = indentOf(cur) > 0; if (!started) { folded = cur; started = true; } else if (blanks > 0) { folded += "\n".repeat(blanks) + cur; } else if (moreIndented || prevMoreIndented) { folded += `\n${cur}`; } else { folded += ` ${cur}`; } blanks = 0; prevMoreIndented = moreIndented; } return { value: folded, next: j }; } function isFlowSequenceClosed(text) { let depth = 0; for (const ch of text) { if (ch === "[") depth++; else if (ch === "]") depth--; } return depth <= 0; } function readFlowSequenceContinuation(lines, start, end, head) { let acc = head; let j = start; for (; j < end; j++) { acc += ` ${lines[j].trim()}`; if (isFlowSequenceClosed(acc)) { j++; break; } } return { value: acc, next: j }; } function unquote(value) { if (value.length < 2) return value; const q = value[0]; if ((q !== '"' && q !== "'") || value[value.length - 1] !== q) return value; const inner = value.slice(1, -1); return q === '"' ? inner.replace(/\\t/g, "\t").replace(/\\n/g, "\n") : inner; } function isInsideRoot(root, p) { if (p === root) return true; const rel = relative(root, p); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); } function listRelativeFiles(base, dir, prefix, out, walk) { let entries; try { entries = readdirSync(join(base, dir), { withFileTypes: true }); } catch { return; } for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { const rel = `${prefix}${e.name}`; const abs = join(base, dir, e.name); const resolved = resolveInsideSkill(abs, rel, walk); if (resolved === undefined) continue; if (resolved.stats.isDirectory()) { if (walk.visited.has(resolved.real)) continue; walk.visited.add(resolved.real); listRelativeFiles(base, join(dir, e.name), `${rel}/`, out, walk); } else if (resolved.stats.isFile()) { out.push(rel); } } } function skillPhysicalRoot(skillDir, skillName, warn) { try { return physicalPathOf(skillDir); } catch (err) { warn({ code: "read_failed", skill: skillName, detail: `the skill directory could not be resolved (${errText(err)}) — attachments skipped and no base directory is stated`, }); return undefined; } } function resolveInsideSkill(abs, rel, walk) { let real; let stats; try { real = physicalPathOf(abs); stats = statSync(abs); } catch (err) { walk.warn({ code: "attachment_skipped", skill: walk.skill, detail: `${rel}: could not be resolved (${errText(err)})` }); return undefined; } if (!isInsideRoot(walk.realRoot, real)) { walk.warn({ code: "attachment_skipped", skill: walk.skill, detail: `${rel}: resolves outside the skill directory — skipped (attachments are collected from the declared skill directory only)`, }); return undefined; } return { real, stats }; } function readAttachments(skillDir, realRoot, skillName, budgetBytes, warn) { const walk = { realRoot, skill: skillName, warn, visited: new Set([realRoot]) }; const relPaths = []; for (const d of RESOURCE_DIRS) { let exists = true; try { statSync(join(skillDir, d)); } catch { exists = false; } if (!exists) continue; const resolved = resolveInsideSkill(join(skillDir, d), d, walk); if (resolved === undefined || !resolved.stats.isDirectory()) continue; walk.visited.add(resolved.real); listRelativeFiles(skillDir, d, `${d}/`, relPaths, walk); } relPaths.sort(); const files = []; let spent = 0; for (const rel of relPaths) { let bytes; try { bytes = readFileSync(join(skillDir, rel)); } catch (err) { warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: unreadable (${errText(err)})` }); continue; } if (!isDecodableText(bytes)) { warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: not text (attachment content is a string; binary is not carried)` }); continue; } if (spent + bytes.byteLength > budgetBytes) { warn({ code: "attachment_skipped", skill: skillName, detail: `${rel}: over the ${budgetBytes}-byte attachment budget for this skill` }); continue; } spent += bytes.byteLength; files.push({ path: rel, content: bytes.toString("utf8") }); } return files; } function isDecodableText(bytes) { if (bytes.includes(0)) return false; const decoded = bytes.toString("utf8"); return Buffer.byteLength(decoded, "utf8") === bytes.byteLength; } function errText(err) { return thrownText(err); } function splitToolNames(declared) { let text = declared.trim(); if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1); const names = []; let current = ""; let depth = 0; const flush = () => { const name = unquote(current.trim()); if (name !== "") names.push(name); current = ""; }; for (const ch of text) { if (ch === "(") { depth++; current += ch; } else if (ch === ")") { if (depth > 0) depth--; current += ch; } else if (depth === 0 && (ch === "," || /\s/.test(ch))) { flush(); } else { current += ch; } } flush(); return names; } const RETIRED_SKILLS_DIRECTORY_OPTION_KEYS = Object.freeze({ deployedTools: "the loader copies a skill's `allowed-tools` / `disallowed-tools` into `SkillSpec.allowedTools` / `disallowedTools` as written and the run's grant table judges them (an entry naming a tool nothing mounts simply never matches) — drop the option", }); export function createSkillsFromDirectory(dir, options = {}) { assertNoRetiredKeys(options, RETIRED_SKILLS_DIRECTORY_OPTION_KEYS, "SkillsDirectoryOptions"); const warn = (w) => { options.onWarning?.(w); }; const budget = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES; let entries; try { const st = statSync(dir); if (!st.isDirectory()) throw new Error("not a directory"); entries = readdirSync(dir, { withFileTypes: true }); } catch (err) { throw new Error(`skills directory could not be read: ${dir} (${errText(err)})`); } const dirNames = []; for (const e of entries) { if (e.isDirectory()) { dirNames.push(e.name); continue; } if (!e.isSymbolicLink()) continue; try { if (statSync(join(dir, e.name)).isDirectory()) dirNames.push(e.name); } catch (err) { warn({ code: "read_failed", skill: e.name, detail: `entry is a symbolic link that could not be resolved (${errText(err)}) — skipped` }); } } dirNames.sort(); const skills = []; for (const name of dirNames) { const skillDir = join(dir, name); let text; try { text = readFileSync(join(skillDir, SKILL_FILE), "utf8"); } catch { warn({ code: "no_skill_file", skill: name, detail: `no ${SKILL_FILE} in this directory — skipped` }); continue; } const parsed = parseSkillFile(text); if (!parsed.hadFrontmatter) { warn({ code: "no_frontmatter", skill: name, detail: `${SKILL_FILE} has no fenced --- frontmatter block — skipped` }); continue; } const declaredName = parsed.fields.get("name"); if (declaredName === undefined || declaredName === "") { warn({ code: "missing_name", skill: name, detail: "frontmatter has no `name` (required) — skipped" }); continue; } if (declaredName.length > NAME_MAX_CHARS || !NAME_RE.test(declaredName)) { warn({ code: "invalid_name", skill: name, detail: `\`name\` must be ≤${NAME_MAX_CHARS} chars of lowercase alphanumerics in hyphen-separated runs — skipped`, }); continue; } if (declaredName !== name) { warn({ code: "name_mismatch", skill: name, detail: `frontmatter \`name\` is "${declaredName}" but the directory is "${name}" — skipped` }); continue; } const description = parsed.fields.get("description"); if (description === undefined || description === "") { warn({ code: "missing_description", skill: name, detail: "frontmatter has no `description` (required) — skipped" }); continue; } if (description.length > DESCRIPTION_MAX_CHARS) { warn({ code: "description_too_long", skill: name, detail: `\`description\` is ${description.length} chars, over the ${DESCRIPTION_MAX_CHARS} cap — skipped` }); continue; } const allowedTools = splitToolNames(parsed.fields.get("allowed-tools") ?? ""); const disallowedTools = splitToolNames(parsed.fields.get("disallowed-tools") ?? ""); const baseDir = skillPhysicalRoot(skillDir, name, warn); const files = baseDir === undefined ? [] : readAttachments(skillDir, baseDir, name, budget, warn); skills.push({ name: declaredName, description, content: parsed.body, ...(baseDir !== undefined ? { baseDir } : {}), ...(allowedTools.length > 0 ? { allowedTools } : {}), ...(disallowedTools.length > 0 ? { disallowedTools } : {}), ...(files.length > 0 ? { files } : {}), }); } return skills; }