@sema-agent/core
Version:
Stateless, task-oriented AI agent core
975 lines (974 loc) • 39.6 kB
JavaScript
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { isFrontmatterFence, UNCLOSED_FRONTMATTER_REASON } from "./frontmatter-fence.js";
import { physicalPathOf } from "./physical-path.js";
import { isPhysicallyWithin } from "../tools/fs/path-containment.js";
import { assertNoRetiredKeys } from "./retired-keys.js";
import { ruleSpellingOf } from "./permission-rule-syntax.js";
import { inlineUntrusted } from "./untrusted-text.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 ALLOWED_TOOLS_KEY = "allowed-tools";
const DISALLOWED_TOOLS_KEY = "disallowed-tools";
const DISALLOWED_TOOLS_ALIAS = "disallowedTools";
const TOOL_LIST_KEYS = new Set([ALLOWED_TOOLS_KEY, DISALLOWED_TOOLS_KEY, DISALLOWED_TOOLS_ALIAS]);
const UNREAD_CONTINUATION = "it is followed by indented lines the frontmatter reader does not read";
const TEXT_AFTER_END = "text follows its closing quote or its closing `]` on the same line";
const SEQUENCE_NOT_AN_ENTRY = "its `- name` entries are not indented alike";
const SEQUENCE_NOT_A_NAME = "an entry is not a plain or quoted name (nested, mapping, anchor, tag, flow and block-scalar entries, and number, boolean and null entries, are not read)";
const VALUE_IS_A_MAPPING = "its value is a mapping or holds one (`key: value` or `{…}`), not a list of names";
const VALUE_NOT_A_STRING = "its value is a number or a boolean, not a list of names";
const QUOTE_NOT_CLOSED = "a quote in it does not close";
const flowNotClosed = (opener) => `its \`${opener}\` does not close within its lines`;
const strayLine = (line) => `a flush-left line that is no key follows it (line ${line})`;
const NOT_A_KEY_LINE = "a flush-left frontmatter line is a key — letters, digits, `_` and `-`, or one quoted scalar — then `:` and a blank or nothing; a comment starts with `#`";
const PARENS_NOT_ONE_ZONE = "an entry's parentheses are not one group that closes it (a `(` that does not close, a `)` with no `(`, or text after the closing `)` — `\\(` / `\\)` are a rule's own text)";
const KEY_REPEATED = "the key is written more than once";
const notARule = (position) => `entry ${position} is no rule spelling (a tool name of ASCII letters, digits and \`_ - . : *\`, starting with a letter, a digit or \`_\`, then at most one parenthesized rule)`;
const keyInsideValue = (inner) => `an indented line under its value is a mapping key, \`${inlineUntrusted(inner, 64)}\` — YAML reads no key there (under a value that is not a mapping, or indented less than the nested block it follows)`;
const toolListOutOfPlace = (inner) => `it holds the tool-list key \`${inner}\` — a tool list is read only as a key of the frontmatter, written flush left; anywhere else its list would be lost`;
const isBlank = (ch) => ch === " " || ch === "\t";
function leadingBlanks(text) {
let n = 0;
while (n < text.length && isBlank(text.charAt(n)))
n++;
return n;
}
const trimLeadingBlanks = (text) => text.slice(leadingBlanks(text));
function trimTrailingBlanks(text) {
let to = text.length;
while (to > 0 && isBlank(text.charAt(to - 1)))
to--;
return text.slice(0, to);
}
const trimBlanks = (text) => trimLeadingBlanks(trimTrailingBlanks(text));
function isSkippable(line) {
const body = trimLeadingBlanks(line);
return body === "" || body.startsWith("#");
}
function keyTokenOf(line) {
const quote = line.charAt(0);
const close = quote === '"' || quote === "'" ? closingQuote(line, 0) : -1;
const key = close > 0 ? decodeQuoted(line.slice(1, close), quote) : /^[A-Za-z][A-Za-z0-9_-]*/.exec(line)?.[0];
if (key === undefined)
return undefined;
return { key, after: trimLeadingBlanks(line.slice(close > 0 ? close + 1 : key.length)) };
}
function keyLineOf(line) {
const token = keyTokenOf(line);
return token !== undefined && token.after.startsWith(":") ? { key: token.key, rest: token.after.slice(1) } : undefined;
}
function yamlKeyLineOf(text) {
const line = keyLineOf(text);
return line !== undefined && (line.rest === "" || isBlank(line.rest.charAt(0))) ? line : undefined;
}
const yamlKeyOf = (text) => yamlKeyLineOf(text)?.key;
const isDashEntry = (text) => text.charAt(0) === "-" && (text.length === 1 || isBlank(text.charAt(1)));
function nestedLineHead(line) {
let text = trimLeadingBlanks(line);
let explicit = false;
for (;;) {
const marker = text.charAt(0);
if (text.length < 2 || (marker !== "-" && marker !== "?") || !isBlank(text.charAt(1)))
break;
explicit ||= marker === "?";
text = trimLeadingBlanks(text.slice(1));
}
const token = keyTokenOf(text);
const keyLine = yamlKeyLineOf(text);
const key = keyLine?.key ?? (explicit && token !== undefined && (token.after === "" || token.after.startsWith("#")) ? token.key : undefined);
if (key !== undefined && TOOL_LIST_KEYS.has(key))
return { key };
const value = trimLeadingBlanks(keyLine !== undefined ? keyLine.rest : text);
return value.charAt(0) === "{" || value.charAt(0) === "[" ? { flow: value } : {};
}
function toolListKeyIn(line) {
const head = nestedLineHead(line);
return head.key ?? (head.flow !== undefined ? scanValue(head.flow, "value").flowKey : undefined);
}
function nestedFlowAt(lines, at, end, flow, holds) {
const floor = Math.max(holds - 1, 0);
for (let budget = flow.length + 1;; budget *= 2) {
const region = valueRegion(lines, at, end, flow, floor, budget, false);
const scan = scanValue(region.lines.join("\n"), "value");
if (scan.unterminated === undefined || !region.capped)
return { ...(scan.flowKey !== undefined ? { key: scan.flowKey } : {}), held: Math.max(1, scan.held) };
}
}
function toolListKeyAfter(trailing) {
for (let p = 0; p < trailing.length; p++) {
if (isBlank(trailing.charAt(p)) || (p > 0 && !isBlank(trailing.charAt(p - 1))))
continue;
const key = yamlKeyOf(trailing.slice(p));
if (key !== undefined && TOOL_LIST_KEYS.has(key))
return key;
}
return undefined;
}
const opensNestedBlock = (text) => isDashEntry(text) || (text.charAt(0) === "?" && (text.length === 1 || isBlank(text.charAt(1)))) || yamlKeyOf(text) !== undefined || scanValue(text, "value").mapping === true;
function parseSkillFile(text) {
const fields = new Map();
const lists = new Map();
const normalized = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
const unread = [];
const refused = new Set();
const refuse = (key, why, list) => {
if (refused.has(key))
return;
refused.add(key);
unread.push({ key, why, list });
};
let strayRefused = false;
const keep = (key, value) => {
if (!fields.has(key))
fields.set(key, value);
};
if (!isFrontmatterFence(lineAt(lines, 0)))
return { fields, lists, body: normalized, hadFrontmatter: false, unclosed: false, unread };
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (isFrontmatterFence(lineAt(lines, i))) {
end = i;
break;
}
}
if (end === -1)
return { fields, lists, body: normalized, hadFrontmatter: false, unclosed: true, unread };
const front = [];
const fileLine = [];
for (let k = 0; k < end; k++) {
for (const piece of lineAt(lines, k).split("\r")) {
front.push(piece);
fileLine.push(k + 1);
}
}
const frontEnd = front.length;
let i = 1;
let owner;
const listKeysSeen = new Set();
const valued = new Set();
let aliasSwallows;
const lost = (at, why) => {
refuse(at.key, why, at.list);
if (at.key === DISALLOWED_TOOLS_ALIAS)
aliasSwallows ??= why;
};
const listLostAfter = (at, trailing) => {
const inner = trailing !== undefined ? toolListKeyAfter(trailing) : undefined;
if (inner !== undefined)
lost(at, toolListOutOfPlace(inner));
};
while (i < frontEnd) {
const line = lineAt(front, i);
if (isSkippable(line)) {
i++;
continue;
}
if (owner !== undefined && (isBlank(line.charAt(0)) ? leadingBlanks(line) >= owner.holds : owner.holds === 0 && isDashEntry(line))) {
const head = nestedLineHead(line);
const flow = head.key === undefined && head.flow !== undefined ? nestedFlowAt(front, i, frontEnd, head.flow, owner.holds) : undefined;
const held = flow?.held ?? 1;
let inner = head.key ?? flow?.key;
for (let at = i + 1; inner === undefined && at < i + held; at++)
inner = toolListKeyIn(lineAt(front, at));
if (inner !== undefined)
lost(owner, toolListOutOfPlace(inner));
i += held;
continue;
}
if (isBlank(line.charAt(0))) {
if (owner !== undefined) {
const inner = yamlKeyOf(trimLeadingBlanks(line));
const list = inner === undefined ? toolListKeyIn(line) : undefined;
if (inner !== undefined)
lost(owner, keyInsideValue(inner));
else if (list !== undefined)
lost(owner, toolListOutOfPlace(list));
else if (owner.list)
refuse(owner.key, UNREAD_CONTINUATION, true);
}
i++;
continue;
}
const keyLine = yamlKeyLineOf(line);
if (keyLine === undefined) {
if (owner?.list === true)
lost(owner, strayLine(fileLine[i] ?? i + 1));
else if (!strayRefused) {
strayRefused = true;
unread.push({ line: fileLine[i] ?? i + 1, why: NOT_A_KEY_LINE });
}
owner = undefined;
i++;
continue;
}
const { key, rest } = keyLine;
const tail = trimBlanks(rest);
owner = { key, list: TOOL_LIST_KEYS.has(key), holds: Number.POSITIVE_INFINITY };
if (owner.list) {
if (listKeysSeen.has(key))
refuse(key, KEY_REPEATED, true);
listKeysSeen.add(key);
const read = readToolList(front, i, frontEnd, trimLeadingBlanks(rest));
i = read.next;
listLostAfter(owner, read.trailing);
if (key === DISALLOWED_TOOLS_ALIAS)
aliasSwallows ??= read.unterminated;
const unspelled = read.problem === undefined ? read.entries.findIndex((e) => ruleSpellingOf(e) === undefined) : -1;
const problem = read.problem ?? (unspelled >= 0 ? notARule(unspelled + 1) : undefined);
if (problem !== undefined || read.null !== true)
valued.add(key);
if (problem !== undefined)
refuse(key, problem, true);
else if (read.null !== true && !lists.has(key))
lists.set(key, read.entries);
continue;
}
const start = valueStartOf(front, i, frontEnd, trimLeadingBlanks(rest));
if (start === undefined || (start.at > i && opensNestedBlock(start.first))) {
owner.holds = start === undefined ? 0 : leadingBlanks(lineAt(front, start.at));
keep(key, tail);
i++;
continue;
}
const block = parseBlockScalarHeader(trimBlanks(start.first));
if (block !== undefined) {
const read = readBlockScalar(front, start.at + 1, frontEnd, block);
keep(key, start.at > i ? tail : read.value);
i = read.next;
continue;
}
const region = valueRegion(front, start.at, frontEnd, start.first, 0);
const scan = scanValue(region.lines.join("\n"), "value");
if (scan.unterminated !== undefined)
refuse(key, scan.unterminated, false);
listLostAfter(owner, scan.trailing);
if (scan.flowKey !== undefined)
lost(owner, toolListOutOfPlace(scan.flowKey));
const flowLines = region.lines.slice(0, scan.held).filter((l) => !isSkippable(l)).map(trimBlanks);
keep(key, start.at > i ? tail : (scan.quoted ?? (tail.startsWith("[") && flowLines.length > 1 ? flowLines.join(" ") : tail)));
i = start.at + scan.held;
}
if (valued.has(DISALLOWED_TOOLS_KEY) && listKeysSeen.has(DISALLOWED_TOOLS_ALIAS)) {
lists.delete(DISALLOWED_TOOLS_ALIAS);
const at = unread.findIndex((u) => "key" in u && u.key === DISALLOWED_TOOLS_ALIAS);
if (at >= 0)
unread.splice(at, 1);
if (aliasSwallows !== undefined)
unread.push({ key: DISALLOWED_TOOLS_ALIAS, why: aliasSwallows, list: false });
}
let body = lines.slice(end + 1).join("\n");
if (body.startsWith("\n"))
body = body.slice(1);
return { fields, lists, body, hadFrontmatter: true, unclosed: false, unread };
}
function lineAt(lines, index) {
return lines[index] ?? "";
}
function valueStartOf(lines, keyIdx, end, value) {
if (!isSkippable(value))
return { at: keyIdx, first: value };
let j = keyIdx + 1;
while (j < end && isSkippable(lineAt(lines, j)))
j++;
const line = lineAt(lines, j);
return j < end && isBlank(line.charAt(0)) ? { at: j, first: trimLeadingBlanks(line) } : undefined;
}
function readToolList(lines, keyIdx, end, value) {
const start = valueStartOf(lines, keyIdx, end, value);
if (start === undefined || (start.at > keyIdx && start.first.startsWith("-")))
return readBlockSequence(lines, keyIdx + 1, end);
return readValue(lines, start.at, end, start.first);
}
function readValue(lines, valueIdx, end, first) {
const header = parseBlockScalarHeader(trimBlanks(first));
if (header !== undefined) {
const read = readBlockScalar(lines, valueIdx + 1, end, header);
const cut = new EntryCutter();
for (const line of read.value.split("\n")) {
for (const ch of line)
cut.push(ch);
cut.endScalar();
}
return { entries: cut.entries, ...(cut.problem !== undefined ? { problem: cut.problem } : {}), next: read.next };
}
const region = valueRegion(lines, valueIdx, end, first, 0);
const { held, mapping: _mapping, quoted: _quoted, flowKey: _flowKey, ...scan } = scanValue(region.lines.join("\n"), "value");
void _mapping;
void _quoted;
void _flowKey;
return { ...scan, next: valueIdx + held };
}
function valueRegion(lines, from, end, first, floor, budget = Number.POSITIVE_INFINITY, flushLeftCloses = true) {
const taken = [first];
const flow = first.startsWith("[") || first.startsWith("{");
let size = first.length + 1;
let j = from + 1;
for (; j < end; j++) {
if (size >= budget)
return { lines: taken, next: j, capped: true };
const line = lineAt(lines, j);
const body = trimLeadingBlanks(line);
const closes = flow && (body.startsWith("]") || body.startsWith("}")) && (flushLeftCloses || body.length < line.length);
if (!isSkippable(line) && leadingBlanks(line) <= floor && !closes)
break;
taken.push(body);
size += body.length + 1;
}
return { lines: taken, next: j, capped: false };
}
function readBlockSequence(lines, start, end) {
const entries = [];
let indent;
let j = start;
while (j < end) {
const line = lineAt(lines, j);
if (isSkippable(line)) {
j++;
continue;
}
const dashAt = leadingBlanks(line);
if (!isDashEntry(line.slice(dashAt)))
break;
const dashIndent = line.slice(0, dashAt);
if (indent !== undefined && dashIndent !== indent)
return { entries, next: j, problem: SEQUENCE_NOT_AN_ENTRY };
indent = dashIndent;
const item = valueRegion(lines, j, end, trimLeadingBlanks(line.slice(dashAt + 1)), dashAt);
const scan = scanValue(item.lines.join("\n"), "item");
if (scan.problem !== undefined)
return { entries, next: j + scan.held, problem: scan.problem, ...(scan.unterminated !== undefined ? { unterminated: scan.unterminated } : {}), ...(scan.trailing !== undefined ? { trailing: scan.trailing } : {}) };
for (const entry of scan.entries)
entries.push(entry);
j += scan.held;
}
return { entries, next: j, ...(indent === undefined ? { null: true } : {}) };
}
const YAML_NULL = /^(?:null|Null|NULL|~)$/;
const YAML_BOOL_OR_NUMBER = /^(?:true|True|TRUE|false|False|FALSE|[-+]?[0-9]+|0o[0-7]+|0x[0-9a-fA-F]+|[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
class EntryCutter {
entries = [];
problem;
depth = 0;
closed = false;
current = "";
blanks = "";
escaping = false;
push(ch) {
const escaped = this.escaping;
this.escaping = ch === "\\" && !escaped;
if (this.depth === 0 && (ch === "," || ch === " ")) {
this.cut();
return;
}
if (isBlank(ch)) {
if (this.current !== "")
this.blanks += ch;
return;
}
if (this.closed)
this.fail(PARENS_NOT_ONE_ZONE);
if (ch === "(" && !escaped)
this.depth++;
else if (ch === ")" && !escaped) {
if (this.depth === 0)
this.fail(PARENS_NOT_ONE_ZONE);
else
this.closed = --this.depth === 0;
}
this.current += this.blanks + ch;
this.blanks = "";
}
fold(breaks) {
this.blanks = "";
if (breaks === 1)
this.push(" ");
else
for (let n = 1; n < breaks; n++)
this.push("\n");
}
endScalar() {
if (this.depth > 0)
this.fail(PARENS_NOT_ONE_ZONE);
this.depth = 0;
this.escaping = false;
this.cut();
}
fail(why) {
this.problem ??= why;
}
cut() {
const entry = this.current;
this.current = "";
this.blanks = "";
this.closed = false;
if (entry !== "")
this.entries.push(entry);
}
}
function scanValue(text, seat) {
const cut = new EntryCutter();
let state = "start";
const opened = [];
const closes = (ch) => opened.length > 0 && ch === (opened[opened.length - 1] === "{" ? "}" : "]");
let held = text.split("\n").length;
let mapping = false;
let inComment = false;
let crossedLine = false;
let plain = "";
let breaks = 0;
let itemFrom = 0;
let isNull = false;
let unterminated;
let quoted;
let trailing;
let flowKey;
let quotedItem;
const endPlain = () => {
const written = trimBlanks(plain);
plain = "";
breaks = 0;
if (opened.length > 0 || seat === "item") {
if (YAML_NULL.test(written) || YAML_BOOL_OR_NUMBER.test(written))
cut.fail(SEQUENCE_NOT_A_NAME);
}
else if (YAML_NULL.test(written))
isNull = true;
else if (YAML_BOOL_OR_NUMBER.test(written))
cut.fail(VALUE_NOT_A_STRING);
cut.endScalar();
};
const mappingMarkAt = (at) => {
const next = text.charAt(at + 1);
return next === "" || next === "\n" || isBlank(next) || (opened.length > 0 && (next === "," || next === "]" || next === "}"));
};
const ended = (item) => {
quotedItem = undefined;
if (item && opened.length > 0 && cut.entries.length === itemFrom)
cut.fail(SEQUENCE_NOT_A_NAME);
state = opened.length > 0 ? "after" : "done";
crossedLine = false;
};
for (let k = 0; k < text.length; k++) {
const ch = text.charAt(k);
if (inComment && ch !== "\n")
continue;
inComment = false;
const comment = ch === "#" && (k === 0 || isBlank(text.charAt(k - 1)) || text.charAt(k - 1) === "\n");
if (state !== "plain") {
if (isBlank(ch))
continue;
if (ch === "\n" || comment) {
crossedLine ||= ch === "\n";
inComment = comment;
continue;
}
if (state === "done") {
if (crossedLine) {
held = linesBefore(text, k);
break;
}
cut.fail(TEXT_AFTER_END);
quoted = undefined;
trailing ??= text.slice(k, lineEndOf(text, k));
continue;
}
if (opened.length > 0 && (ch === "," || ch === "]" || ch === "}")) {
if (ch === ",")
state = "start";
else if (closes(ch)) {
opened.pop();
ended(false);
}
else
cut.fail(SEQUENCE_NOT_A_NAME);
continue;
}
if (state === "after" && ch === ":") {
cut.fail(VALUE_IS_A_MAPPING);
if (quotedItem !== undefined && TOOL_LIST_KEYS.has(quotedItem))
flowKey ??= quotedItem;
state = "start";
continue;
}
if (state === "after")
cut.fail(TEXT_AFTER_END);
const opens = state === "start";
itemFrom = cut.entries.length;
if (opens && (ch === '"' || ch === "'")) {
const close = closingQuote(text, k);
if (close < 0) {
cut.fail((unterminated = QUOTE_NOT_CLOSED));
break;
}
const decoded = decodeQuoted(text.slice(k + 1, close), ch);
if (opened.length === 0)
quoted = decoded;
for (const c of decoded)
cut.push(c);
cut.endScalar();
ended(true);
if (opened.length > 0)
quotedItem = decoded;
k = close;
continue;
}
if (opens && (ch === "[" || ch === "{") && (opened.length > 0 || seat === "value")) {
if (opened.length > 0)
cut.fail(SEQUENCE_NOT_A_NAME);
else if (ch === "{")
cut.fail(VALUE_IS_A_MAPPING);
opened.push(ch);
continue;
}
state = "plain";
}
if (ch === "\n") {
breaks++;
continue;
}
if (breaks > 0 && opened.length === 0 && yamlKeyOf(trimLeadingBlanks(text.slice(k, lineEndOf(text, k)))) !== undefined) {
endPlain();
ended(true);
held = linesBefore(text, k);
break;
}
if (comment) {
endPlain();
ended(true);
inComment = true;
continue;
}
if (opened.length > 0 && (ch === "," || closes(ch))) {
endPlain();
ended(true);
if (ch === ",")
state = "start";
else {
opened.pop();
ended(false);
}
continue;
}
if (opened.length > 0 && (ch === "[" || ch === "{" || ch === "]" || ch === "}")) {
cut.fail(SEQUENCE_NOT_A_NAME);
if (ch === "[" || ch === "{")
opened.push(ch);
}
if (ch === ":" && mappingMarkAt(k)) {
cut.fail(VALUE_IS_A_MAPPING);
if (opened.length === 0)
mapping = true;
else {
const written = trimBlanks(plain);
if (TOOL_LIST_KEYS.has(written))
flowKey ??= written;
endPlain();
state = "start";
continue;
}
}
if (breaks > 0) {
cut.fold(breaks);
plain += breaks === 1 ? " " : "\n".repeat(breaks - 1);
breaks = 0;
}
cut.push(ch);
plain += ch;
}
if (opened.length > 0) {
const why = flowNotClosed(opened[0] ?? "[");
cut.fail(why);
unterminated ??= why;
}
if (state === "plain")
endPlain();
if (seat === "item" && cut.entries.length === 0)
cut.fail(SEQUENCE_NOT_A_NAME);
return {
entries: isNull ? [] : cut.entries,
...(cut.problem !== undefined ? { problem: cut.problem } : {}),
...(unterminated !== undefined ? { unterminated } : {}),
...(isNull ? { null: true } : {}),
held,
...(mapping ? { mapping: true } : {}),
...(quoted !== undefined ? { quoted } : {}),
...(trailing !== undefined ? { trailing } : {}),
...(flowKey !== undefined ? { flowKey } : {}),
};
}
function linesBefore(text, at) {
let n = 0;
for (let k = text.indexOf("\n"); k >= 0 && k < at; k = text.indexOf("\n", k + 1))
n++;
return n;
}
function lineEndOf(text, at) {
const k = text.indexOf("\n", at);
return k < 0 ? text.length : k;
}
function closingQuote(text, open) {
const quote = text.charAt(open);
for (let k = open + 1; k < text.length; k++) {
const ch = text.charAt(k);
if (quote === '"' && ch === "\\")
k++;
else if (ch === quote) {
if (quote === "'" && text.charAt(k + 1) === "'")
k++;
else
return k;
}
}
return -1;
}
const DOUBLE_QUOTE_ESCAPES = new Map(Object.entries({ 0: 0, a: 7, b: 8, t: 9, "\t": 9, n: 10, v: 11, f: 12, r: 13, e: 0x1b, " ": 32, '"': 34, "/": 47, "\\": 92, N: 0x85, _: 0xa0, L: 0x2028, P: 0x2029 }).map(([letter, code]) => [letter, String.fromCharCode(code)]));
function decodeQuoted(inner, quote) {
let out = "";
let blanks = "";
let breaks = 0;
let joined = false;
const fold = () => (breaks === 0 ? blanks : joined ? "\n".repeat(breaks) : breaks === 1 ? " " : "\n".repeat(breaks - 1));
for (let k = 0; k < inner.length; k++) {
const ch = inner.charAt(k);
if (ch === "\n") {
blanks = "";
breaks++;
continue;
}
if (isBlank(ch)) {
if (breaks === 0 && !joined)
blanks += ch;
continue;
}
out += fold();
blanks = "";
breaks = 0;
joined = false;
if (quote === "'" || ch !== "\\") {
out += ch;
if (quote === "'" && ch === "'")
k++;
continue;
}
const escape = /^(?:x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[\s\S])/.exec(inner.slice(k + 1, k + 10))?.[0] ?? "";
const code = escape.length > 1 ? Number.parseInt(escape.slice(1), 16) : -1;
if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
out += `\\${escape.charAt(0)}`;
k += 1;
continue;
}
k += escape.length;
joined = escape === "\n";
if (joined)
continue;
out += code >= 0 ? String.fromCodePoint(code) : (DOUBLE_QUOTE_ESCAPES.get(escape) ?? `\\${escape}`);
}
return out + fold();
}
function parseBlockScalarHeader(value) {
const m = /^([|>])([0-9+-]*)([\s\S]*)$/.exec(value);
if (!m)
return undefined;
const [, style = "", rest = "", comment = ""] = m;
if (comment !== "" && !(isBlank(comment.charAt(0)) && trimLeadingBlanks(comment).startsWith("#")))
return undefined;
if (!/^(?:[1-9]?[+-]?|[+-][1-9]?)$/.test(rest))
return undefined;
const digit = /[1-9]/.exec(rest)?.[0];
return { style: style === "|" ? "literal" : "folded", ...(digit !== undefined ? { indent: Number(digit) } : {}) };
}
function readBlockScalar(lines, start, end, header) {
const indentOf = leadingBlanks;
let indent = header.indent;
if (indent === undefined) {
for (let j = start; j < end; j++) {
if (trimBlanks(lineAt(lines, j)) === "")
continue;
indent = indentOf(lineAt(lines, j));
break;
}
}
if (indent === undefined || indent === 0)
return { value: "", next: start };
const taken = [];
let j = start;
for (; j < end; j++) {
const line = lineAt(lines, j);
if (trimBlanks(line) === "") {
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 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 (!isPhysicallyWithin(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);
}
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 containWithin = options.containWithin;
if (containWithin !== undefined) {
const isDir = typeof containWithin === "string" && statSync(containWithin, { throwIfNoEntry: false })?.isDirectory() === true;
if (!isDir)
throw new Error(`skills directory containment root is not a readable directory: ${String(containWithin)}`);
}
const outside = (skill, path, what) => {
if (containWithin === undefined || isPhysicallyWithin(containWithin, path))
return false;
warn({ code: "outside_root", skill, detail: `${what} resolves outside ${containWithin} — skipped` });
return true;
};
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);
if (outside(name, skillDir, "its directory") || outside(name, join(skillDir, SKILL_FILE), SKILL_FILE))
continue;
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) {
const detail = parsed.unclosed ? `${SKILL_FILE} ${UNCLOSED_FRONTMATTER_REASON} — skipped` : `${SKILL_FILE} has no fenced --- frontmatter block — skipped`;
warn({ code: "no_frontmatter", skill: name, detail });
continue;
}
if (parsed.unread.length > 0) {
const lists = parsed.unread.some((u) => "list" in u && u.list);
const said = parsed.unread.map((u) => ("line" in u ? `${SKILL_FILE} line ${u.line} is no key line (${u.why})` : `\`${u.key}\` has a value the reader cannot read${u.list ? " as a list" : ""} (${u.why})`));
warn({
code: "no_frontmatter",
skill: name,
detail: `${said.join("; ")}${lists ? " — write each list as one value (`a b`, `a, b`, `[a, b]`), a block scalar, or `- name` lines under the key" : ""} — skipped${lists ? ", not installed with a missing or shorter list" : ""}`,
});
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 = parsed.lists.get(ALLOWED_TOOLS_KEY) ?? [];
const disallowedTools = parsed.lists.get(DISALLOWED_TOOLS_KEY) ?? parsed.lists.get(DISALLOWED_TOOLS_ALIAS) ?? [];
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;
}