@sema-agent/core
Version:
Stateless, task-oriented AI agent core
415 lines (414 loc) • 14.3 kB
JavaScript
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;
}