UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

975 lines (974 loc) • 39.6 kB
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; }