@mastra/core
Version:
1,457 lines (1,453 loc) • 63.2 kB
JavaScript
import * as fs2 from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import picomatch from 'picomatch';
import matter from 'gray-matter';
// src/workspace/errors.ts
var WorkspaceError = class extends Error {
constructor(message, code, workspaceId) {
super(message);
this.code = code;
this.workspaceId = workspaceId;
this.name = "WorkspaceError";
}
code;
workspaceId;
};
var WorkspaceNotAvailableError = class extends WorkspaceError {
constructor() {
super("Workspace not available. Ensure the agent has a workspace configured.", "NO_WORKSPACE");
this.name = "WorkspaceNotAvailableError";
}
};
var FilesystemNotAvailableError = class extends WorkspaceError {
constructor() {
super("Workspace does not have a filesystem configured", "NO_FILESYSTEM");
this.name = "FilesystemNotAvailableError";
}
};
var SandboxNotAvailableError = class extends WorkspaceError {
constructor(message) {
super(message ?? "Workspace does not have a sandbox configured", "NO_SANDBOX");
this.name = "SandboxNotAvailableError";
}
};
var SandboxFeatureNotSupportedError = class extends WorkspaceError {
constructor(feature) {
super(`Sandbox does not support ${feature}`, "FEATURE_NOT_SUPPORTED");
this.name = "SandboxFeatureNotSupportedError";
}
};
var SearchNotAvailableError = class extends WorkspaceError {
constructor() {
super("Workspace does not have search configured (enable bm25 or provide vectorStore + embedder)", "NO_SEARCH");
this.name = "SearchNotAvailableError";
}
};
var WorkspaceNotReadyError = class extends WorkspaceError {
constructor(workspaceId, status) {
super(`Workspace is not ready (status: ${status})`, "NOT_READY", workspaceId);
this.name = "WorkspaceNotReadyError";
}
};
var WorkspaceReadOnlyError = class extends WorkspaceError {
constructor(operation) {
super(`Workspace is in read-only mode. Cannot perform: ${operation}`, "READ_ONLY");
this.name = "WorkspaceReadOnlyError";
}
};
var FilesystemError = class extends Error {
constructor(message, code, path3) {
super(message);
this.code = code;
this.path = path3;
this.name = "FilesystemError";
}
code;
path;
};
var FileNotFoundError = class extends FilesystemError {
constructor(path3) {
super(`File not found: ${path3}`, "ENOENT", path3);
this.name = "FileNotFoundError";
}
};
var DirectoryNotFoundError = class extends FilesystemError {
constructor(path3) {
super(`Directory not found: ${path3}`, "ENOENT", path3);
this.name = "DirectoryNotFoundError";
}
};
var FileExistsError = class extends FilesystemError {
constructor(path3) {
super(`File already exists: ${path3}`, "EEXIST", path3);
this.name = "FileExistsError";
}
};
var IsDirectoryError = class extends FilesystemError {
constructor(path3) {
super(`Path is a directory: ${path3}`, "EISDIR", path3);
this.name = "IsDirectoryError";
}
};
var NotDirectoryError = class extends FilesystemError {
constructor(path3) {
super(`Path is not a directory: ${path3}`, "ENOTDIR", path3);
this.name = "NotDirectoryError";
}
};
var DirectoryNotEmptyError = class extends FilesystemError {
constructor(path3) {
super(`Directory not empty: ${path3}`, "ENOTEMPTY", path3);
this.name = "DirectoryNotEmptyError";
}
};
var PermissionError = class extends FilesystemError {
constructor(path3, operation) {
super(`Permission denied: ${operation} on ${path3}`, "EACCES", path3);
this.operation = operation;
this.name = "PermissionError";
}
operation;
};
var FileReadRequiredError = class extends FilesystemError {
constructor(path3, reason) {
super(reason, "EREAD_REQUIRED", path3);
this.name = "FileReadRequiredError";
}
};
var StaleFileError = class extends FilesystemError {
constructor(path3, expectedMtime, actualMtime) {
super(
`File was modified externally: ${path3} (expected mtime ${expectedMtime.toISOString()}, actual ${actualMtime.toISOString()})`,
"ESTALE",
path3
);
this.expectedMtime = expectedMtime;
this.actualMtime = actualMtime;
this.name = "StaleFileError";
}
expectedMtime;
actualMtime;
};
var FilesystemNotReadyError = class extends FilesystemError {
constructor(id) {
super(`Filesystem "${id}" is not ready. Call init() first or use ensureReady().`, "ENOTREADY", id);
this.name = "FilesystemNotReadyError";
}
};
function expandTilde(p) {
if (p === "~") return os.homedir();
if (p.startsWith("~/") || p.startsWith("~\\")) {
return path.join(os.homedir(), p.slice(2));
}
return p;
}
function isEnoentError(error) {
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
}
function isEexistError(error) {
return error !== null && typeof error === "object" && "code" in error && error.code === "EEXIST";
}
var MIME_TYPES = {
// Text
txt: "text/plain",
html: "text/html",
htm: "text/html",
css: "text/css",
csv: "text/csv",
md: "text/markdown",
// Code
js: "application/javascript",
mjs: "application/javascript",
ts: "application/typescript",
tsx: "application/typescript",
jsx: "application/javascript",
json: "application/json",
xml: "application/xml",
yaml: "text/yaml",
yml: "text/yaml",
// Programming languages
py: "text/x-python",
rb: "text/x-ruby",
go: "text/x-go",
rs: "text/x-rust",
java: "text/x-java",
c: "text/x-c",
cpp: "text/x-c++",
h: "text/x-c",
hpp: "text/x-c++",
sh: "text/x-sh",
bash: "text/x-sh",
zsh: "text/x-sh",
// Config
toml: "text/toml",
ini: "text/plain",
env: "text/plain",
// Database/Query
sql: "text/x-sql",
graphql: "application/graphql",
gql: "application/graphql",
// Frameworks
vue: "text/x-vue",
svelte: "text/x-svelte",
// Web styles
scss: "text/x-scss",
sass: "text/x-sass",
less: "text/x-less",
// Additional languages
php: "application/x-php",
swift: "text/x-swift",
kt: "text/x-kotlin",
kts: "text/x-kotlin",
dart: "application/dart",
lua: "text/x-lua",
r: "text/x-r",
tf: "text/x-terraform",
tfvars: "text/x-terraform",
mdx: "text/markdown",
// Images
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
webp: "image/webp",
ico: "image/x-icon",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
heic: "image/heic",
heif: "image/heif",
avif: "image/avif",
// Documents
pdf: "application/pdf",
// Audio
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
aac: "audio/aac",
// Video
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
// Archives
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
tgz: "application/gzip",
bz2: "application/x-bzip2",
"7z": "application/x-7z-compressed",
rar: "application/vnd.rar",
// Executables / binaries
exe: "application/vnd.microsoft.portable-executable",
dll: "application/vnd.microsoft.portable-executable",
so: "application/x-sharedlib",
dylib: "application/x-sharedlib",
bin: "application/x-binary",
dat: "application/x-binary",
// Disk images / packages
dmg: "application/x-apple-diskimage",
iso: "application/x-iso9660-image",
deb: "application/vnd.debian.binary-package",
rpm: "application/x-rpm",
// Office documents
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
// Fonts
ttf: "font/ttf",
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
// Compiled code
wasm: "application/wasm",
class: "application/java-vm",
pyc: "application/x-python-code"
};
function getMimeType(filename) {
const ext = path.extname(filename).slice(1).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
".md",
".txt",
".json",
".yaml",
".yml",
".js",
".mjs",
".ts",
".tsx",
".jsx",
".py",
".rb",
".go",
".rs",
".java",
".c",
".cpp",
".h",
".hpp",
".sh",
".bash",
".zsh",
".html",
".htm",
".css",
".xml",
".toml",
".ini",
".env",
".csv",
".sql",
".graphql",
".gql",
".vue",
".svg",
".mdx",
".scss",
".sass",
".less",
".svelte",
".php",
".swift",
".kt",
".kts",
".dart",
".lua",
".r",
".tf",
".tfvars"
]);
function isTextFile(filename) {
const ext = path.extname(filename).toLowerCase();
return TEXT_EXTENSIONS.has(ext);
}
function resolveToBasePath(basePath, filePath) {
const expanded = expandTilde(filePath);
if (path.isAbsolute(expanded)) {
return path.normalize(expanded);
}
return path.resolve(basePath, expanded);
}
async function fsExists(absolutePath) {
try {
await fs2.access(absolutePath);
return true;
} catch {
return false;
}
}
async function fsStat(absolutePath, userPath) {
try {
const stats = await fs2.stat(absolutePath);
return {
name: path.basename(absolutePath),
type: stats.isDirectory() ? "directory" : "file",
size: stats.size,
createdAt: stats.birthtime,
modifiedAt: stats.mtime,
mimeType: stats.isFile() ? getMimeType(absolutePath) : void 0
};
} catch (error) {
if (isEnoentError(error)) {
throw new FileNotFoundError(userPath);
}
throw error;
}
}
// src/workspace/skills/local-skill-source.ts
var LocalSkillSource = class {
#basePath;
constructor(options = {}) {
this.#basePath = options.basePath ?? process.cwd();
}
/**
* Resolve a path relative to the base path.
* Handles both absolute and relative paths.
*/
#resolvePath(skillPath) {
if (path.isAbsolute(skillPath)) {
return skillPath;
}
return path.resolve(this.#basePath, skillPath);
}
async exists(skillPath) {
return fsExists(this.#resolvePath(skillPath));
}
async stat(skillPath) {
return fsStat(this.#resolvePath(skillPath), skillPath);
}
async readFile(skillPath) {
const resolved = this.#resolvePath(skillPath);
const content = await fs2.readFile(resolved);
if (isTextFile(skillPath)) {
return content.toString("utf-8");
}
return content;
}
async readdir(skillPath) {
const resolved = this.#resolvePath(skillPath);
const entries = await fs2.readdir(resolved, { withFileTypes: true });
return Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(resolved, entry.name);
const isSymlink = entry.isSymbolicLink();
let type = entry.isDirectory() ? "directory" : "file";
if (isSymlink) {
try {
const targetStat = await fs2.stat(entryPath);
type = targetStat.isDirectory() ? "directory" : "file";
} catch {
type = "file";
}
}
return {
name: entry.name,
type,
isSymlink: isSymlink || void 0
};
})
);
}
async realpath(skillPath) {
return fs2.realpath(this.#resolvePath(skillPath));
}
};
var GLOB_CHARS = /[*?{}[\]]/;
function isGlobPattern(input) {
return GLOB_CHARS.test(input);
}
function extractGlobBase(pattern) {
const firstMeta = pattern.search(GLOB_CHARS);
if (firstMeta === -1) {
return pattern;
}
const prefix = pattern.slice(0, firstMeta);
const lastSlash = prefix.lastIndexOf("/");
if (lastSlash <= 0) {
return ".";
}
return prefix.slice(0, lastSlash);
}
function normalizeForMatch(input) {
if (input.startsWith("./")) return input.slice(2);
if (input.startsWith("/")) return input.slice(1);
return input;
}
function createGlobMatcher(patterns, options) {
const patternArray = (Array.isArray(patterns) ? patterns : [patterns]).map(normalizeForMatch);
const matcher = picomatch(patternArray, {
posix: true,
dot: options?.dot ?? false
});
return (path3) => matcher(normalizeForMatch(path3));
}
function matchGlob(path3, pattern, options) {
return createGlobMatcher(pattern, options)(path3);
}
async function walkAll(readdir2, dir, depth, maxDepth) {
if (depth >= maxDepth) return [];
try {
const entries = await readdir2(dir);
const results = [];
for (const entry of entries) {
if (entry.type === "directory" && entry.isSymlink) continue;
const fullPath = dir === "." || dir === "" ? entry.name : `${dir}/${entry.name}`;
results.push({ path: fullPath, type: entry.type });
if (entry.type === "directory") {
results.push(...await walkAll(readdir2, fullPath, depth + 1, maxDepth));
}
}
return results;
} catch {
return [];
}
}
async function resolvePathPattern(pattern, readdir2, options) {
const maxDepth = options?.maxDepth ?? 10;
const normalized = pattern.length > 1 && pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
if (!isGlobPattern(normalized)) {
try {
await readdir2(normalized);
return [{ path: normalized, type: "directory" }];
} catch {
return [{ path: normalized, type: "file" }];
}
}
const walkRoot = extractGlobBase(normalized);
const matcher = createGlobMatcher(normalized, { dot: options?.dot ?? false });
const allEntries = await walkAll(readdir2, walkRoot, 0, maxDepth);
return allEntries.filter((entry) => matcher(entry.path));
}
// src/workspace/skills/schemas.ts
var SKILL_LIMITS = {
/** Recommended max tokens for instructions */
MAX_INSTRUCTION_TOKENS: 5e3,
/** Recommended max lines for SKILL.md */
MAX_INSTRUCTION_LINES: 500,
/** Max characters for name field */
MAX_NAME_LENGTH: 64,
/** Max characters for description field */
MAX_DESCRIPTION_LENGTH: 1024};
function validateSkillName(name) {
const errors = [];
const fieldPath = "name";
if (typeof name !== "string") {
errors.push(`${fieldPath}: Expected string, received ${typeof name}`);
return errors;
}
if (name.length === 0) {
errors.push(`${fieldPath}: Skill name cannot be empty`);
return errors;
}
if (name.length > SKILL_LIMITS.MAX_NAME_LENGTH) {
errors.push(`${fieldPath}: Skill name must be ${SKILL_LIMITS.MAX_NAME_LENGTH} characters or less`);
}
if (!/^[a-z0-9-]+$/.test(name)) {
errors.push(`${fieldPath}: Skill name must contain only lowercase letters, numbers, and hyphens`);
}
if (name.startsWith("-") || name.endsWith("-")) {
errors.push(`${fieldPath}: Skill name must not start or end with a hyphen`);
}
if (name.includes("--")) {
errors.push(`${fieldPath}: Skill name must not contain consecutive hyphens`);
}
return errors;
}
function validateSkillDescription(description) {
const errors = [];
const fieldPath = "description";
if (typeof description !== "string") {
errors.push(`${fieldPath}: Expected string, received ${typeof description}`);
return errors;
}
if (description.length === 0) {
errors.push(`${fieldPath}: Skill description cannot be empty`);
return errors;
}
if (description.length > SKILL_LIMITS.MAX_DESCRIPTION_LENGTH) {
errors.push(`${fieldPath}: Skill description must be ${SKILL_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`);
}
if (description.trim().length === 0) {
errors.push(`${fieldPath}: Skill description cannot be only whitespace`);
}
return errors;
}
function validateSkillLicense(license) {
const errors = [];
const fieldPath = "license";
if (license === void 0 || license === null) {
return errors;
}
if (typeof license !== "string") {
errors.push(`${fieldPath}: Expected string, received ${typeof license}`);
}
return errors;
}
function validateSkillCompatibility(_compatibility) {
return [];
}
function validateSkillMetadataField(metadata) {
const errors = [];
const fieldPath = "metadata";
if (metadata === void 0 || metadata === null) {
return errors;
}
if (typeof metadata !== "object" || Array.isArray(metadata)) {
errors.push(`${fieldPath}: Expected object, received ${Array.isArray(metadata) ? "array" : typeof metadata}`);
return errors;
}
return errors;
}
function validateUserInvocable(userInvocable) {
if (userInvocable === void 0 || typeof userInvocable === "boolean") return [];
return [`user-invocable: Expected boolean, received ${typeof userInvocable}`];
}
function estimateTokens(text) {
const words = text.split(/\s+/).filter(Boolean).length;
return Math.ceil(words * 1.3);
}
function countLines(text) {
return text.split("\n").length;
}
function validateSkillMetadata(metadata, dirName, instructions) {
const errors = [];
const warnings = [];
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
errors.push(
`Expected object, received ${metadata === null ? "null" : Array.isArray(metadata) ? "array" : typeof metadata}`
);
return { valid: false, errors, warnings };
}
const data = metadata;
errors.push(...validateSkillName(data.name));
errors.push(...validateSkillDescription(data.description));
errors.push(...validateSkillLicense(data.license));
errors.push(...validateSkillCompatibility());
errors.push(...validateUserInvocable(data["user-invocable"]));
errors.push(...validateSkillMetadataField(data.metadata));
if (dirName && typeof data.name === "string" && data.name !== dirName) {
errors.push(`Skill name "${data.name}" must match directory name "${dirName}"`);
}
if (instructions) {
const lineCount = countLines(instructions);
const tokenEstimate = estimateTokens(instructions);
if (lineCount > SKILL_LIMITS.MAX_INSTRUCTION_LINES) {
warnings.push(
`Instructions have ${lineCount} lines (recommended: <${SKILL_LIMITS.MAX_INSTRUCTION_LINES}). Consider moving content to references/.`
);
}
if (tokenEstimate > SKILL_LIMITS.MAX_INSTRUCTION_TOKENS) {
warnings.push(
`Instructions have ~${tokenEstimate} estimated tokens (recommended: <${SKILL_LIMITS.MAX_INSTRUCTION_TOKENS}). Consider moving content to references/.`
);
}
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
// src/skills/create-skill.ts
function createSkill(input) {
const { name, description, instructions, license, compatibility, metadata, references } = input;
const validation = validateSkillMetadata(
{ name, description, license, compatibility, "user-invocable": input["user-invocable"], metadata },
void 0,
instructions
);
if (!validation.valid) {
throw new Error(`Invalid skill "${name}": ${validation.errors.join("; ")}`);
}
const referenceKeys = references ? Object.keys(references) : [];
return {
__inline: true,
__referenceContents: references ?? {},
name,
description,
instructions,
license,
compatibility,
"user-invocable": input["user-invocable"],
metadata,
// Inline skills use a synthetic path: `inline/<name>`
path: `inline/${name}`,
source: { type: "local", projectPath: `inline/${name}` },
references: referenceKeys,
scripts: [],
assets: []
};
}
function isInlineSkill(skill) {
return typeof skill === "object" && skill !== null && "__inline" in skill && skill.__inline === true;
}
var InlineSkillSource = class {
#skills;
/** Pre-built SKILL.md content per skill name */
#skillMdCache;
constructor(skills) {
this.#skills = new Map(skills.map((s) => [s.name, s]));
this.#skillMdCache = /* @__PURE__ */ new Map();
for (const skill of skills) {
this.#skillMdCache.set(skill.name, this.#buildSkillMd(skill));
}
}
/**
* Build a synthetic SKILL.md from an inline skill's metadata and instructions.
*/
#buildSkillMd(skill) {
const frontmatter = {
name: skill.name,
description: skill.description
};
if (skill.license) frontmatter.license = skill.license;
if (skill.compatibility) frontmatter.compatibility = skill.compatibility;
if (skill["user-invocable"] !== void 0) frontmatter["user-invocable"] = skill["user-invocable"];
if (skill.metadata) frontmatter.metadata = skill.metadata;
return matter.stringify(skill.instructions, frontmatter);
}
/**
* Parse a path into skill name and relative sub-path.
* Paths look like `inline/<name>`, `inline/<name>/SKILL.md`, `inline/<name>/references/file.md`
*/
#parsePath(inputPath) {
const prefix = "inline/";
if (!inputPath.startsWith(prefix)) return null;
const rest = inputPath.slice(prefix.length);
const slashIdx = rest.indexOf("/");
if (slashIdx === -1) {
return { skillName: rest, subPath: "" };
}
return { skillName: rest.slice(0, slashIdx), subPath: rest.slice(slashIdx + 1) };
}
#getSkill(inputPath) {
const parsed = this.#parsePath(inputPath);
if (!parsed) return null;
const skill = this.#skills.get(parsed.skillName);
if (!skill) return null;
return { skill, subPath: parsed.subPath };
}
async exists(path3) {
const result = this.#getSkill(path3);
if (!result) return false;
const { skill, subPath } = result;
if (subPath === "") return true;
if (subPath === "SKILL.md") return true;
if (subPath === "references") return (skill.references?.length ?? 0) > 0;
if (subPath.startsWith("references/")) {
const refPath = subPath.slice("references/".length);
return skill.references.includes(refPath);
}
return false;
}
async stat(path3) {
const result = this.#getSkill(path3);
if (!result) {
throw new Error(`ENOENT: no such file or directory: ${path3}`);
}
const { skill, subPath } = result;
const now = /* @__PURE__ */ new Date();
if (subPath === "") {
return { name: skill.name, type: "directory", size: 0, createdAt: now, modifiedAt: now };
}
if (subPath === "SKILL.md") {
const content = this.#skillMdCache.get(skill.name) ?? "";
return {
name: "SKILL.md",
type: "file",
size: Buffer.byteLength(content, "utf-8"),
createdAt: now,
modifiedAt: now,
mimeType: "text/markdown"
};
}
if (subPath === "references") {
return { name: "references", type: "directory", size: 0, createdAt: now, modifiedAt: now };
}
throw new Error(`ENOENT: no such file or directory: ${path3}`);
}
async readFile(path3) {
const result = this.#getSkill(path3);
if (!result) {
throw new Error(`ENOENT: no such file or directory: ${path3}`);
}
const { skill, subPath } = result;
if (subPath === "SKILL.md") {
return this.#skillMdCache.get(skill.name) ?? "";
}
if (subPath.startsWith("references/")) {
const refPath = subPath.slice("references/".length);
const content = skill.__referenceContents[refPath];
if (content !== void 0) return content;
}
throw new Error(`ENOENT: no such file or directory: ${path3}`);
}
async readdir(path3) {
const parsed = this.#parsePath(path3);
if (!parsed) return [];
const { skillName, subPath } = parsed;
const skill = this.#skills.get(skillName);
if (!skill) return [];
if (subPath === "") {
const entries = [{ name: "SKILL.md", type: "file" }];
if (skill.references.length > 0) {
entries.push({ name: "references", type: "directory" });
}
return entries;
}
if (subPath === "references") {
return skill.references.map((ref) => ({ name: ref, type: "file" }));
}
return [];
}
};
var WorkspaceSkillsImpl = class _WorkspaceSkillsImpl {
#source;
#skillsResolver;
#searchEngine;
#validateOnLoad;
#checkSkillFileMtime;
/** Map of skill name -> array of candidates (supports same-named skills from different sources) */
#skills = /* @__PURE__ */ new Map();
/** Whether skills have been discovered */
#initialized = false;
/** Promise for ongoing initialization (prevents concurrent discovery) */
#initPromise = null;
/** Timestamp of last skills discovery (for staleness check) */
#lastDiscoveryTime = 0;
/** Currently resolved skills paths (used to detect changes) */
#resolvedPaths = [];
/** Cached glob-resolved directories and per-pattern resolve timestamps */
#globDirCache = /* @__PURE__ */ new Map();
#globResolveTimes = /* @__PURE__ */ new Map();
static GLOB_RESOLVE_INTERVAL = 5e3;
// Re-walk glob dirs every 5s
static STALENESS_CHECK_COOLDOWN = 2e3;
// Skip staleness check for 2s after discovery
constructor(config) {
this.#source = config.source;
this.#skillsResolver = config.skills;
this.#searchEngine = config.searchEngine;
this.#validateOnLoad = config.validateOnLoad ?? true;
this.#checkSkillFileMtime = config.checkSkillFileMtime ?? false;
}
// ===========================================================================
// Discovery
// ===========================================================================
async list() {
await this.#ensureInitialized();
const results = [];
for (const candidates of this.#skills.values()) {
const canonicalCandidates = await this.#dedupeCanonicalCandidates(candidates);
for (const skill of canonicalCandidates) {
results.push({
name: skill.name,
path: skill.path,
description: skill.description,
license: skill.license,
compatibility: skill.compatibility,
"user-invocable": skill["user-invocable"],
metadata: skill.metadata
});
}
}
return results;
}
async get(name) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(name) ?? this.#resolveByPath(name);
if (!skill) return null;
const { indexableContent: _, ...skillData } = skill;
return skillData;
}
async has(name) {
await this.#ensureInitialized();
return (await this.#resolveByName(name) ?? this.#resolveByPath(name)) !== null;
}
// ===========================================================================
// Skill Resolution (Private)
// ===========================================================================
/**
* Resolve a skill by name with tie-breaking when multiple candidates exist.
* Priority: local > managed > external, then alphabetical path.
*/
async #resolveByName(name) {
const candidates = this.#skills.get(name);
if (!candidates || candidates.length === 0) return null;
return this.#tieBreak(candidates);
}
/**
* Resolve a skill by exact path (escape hatch for disambiguation).
* Searches across all candidate arrays.
* Accepts paths with or without a trailing `/SKILL.md` suffix, since
* SkillsProcessor.formatLocation() exposes `${path}/SKILL.md` to the LLM.
*/
#resolveByPath(skillPath) {
const normalized = skillPath.replace(/\/SKILL\.md$/, "");
for (const candidates of this.#skills.values()) {
const match = candidates.find((s) => s.path === normalized);
if (match) return match;
}
return null;
}
async #getCanonicalSkillPath(skillPath) {
if (!this.#source.realpath) return skillPath;
try {
return await this.#source.realpath(skillPath);
} catch {
return skillPath;
}
}
async #dedupeCanonicalCandidates(candidates) {
const canonicalGroups = /* @__PURE__ */ new Map();
for (const candidate of candidates) {
const canonicalPath = await this.#getCanonicalSkillPath(candidate.path);
const group = canonicalGroups.get(canonicalPath) ?? [];
group.push(candidate);
canonicalGroups.set(canonicalPath, group);
}
const SOURCE_PRIORITY = { local: 0, managed: 1, external: 2 };
return [...canonicalGroups.values()].map(
(group) => [...group].sort((a, b) => {
const aPri = SOURCE_PRIORITY[a.source.type] ?? 99;
const bPri = SOURCE_PRIORITY[b.source.type] ?? 99;
if (aPri !== bPri) return aPri - bPri;
return a.path.localeCompare(b.path);
})[0]
);
}
/**
* Pick the winning skill from an array of same-named candidates.
* When there's only one candidate, returns it directly (no warning).
* When there are multiple, de-duplicates alias paths that point to the same
* canonical skill, then applies source-type priority and warns.
*
* Priority: local (0) > managed (1) > external (2).
* Throws if source-type priority can't resolve the tie (e.g., two distinct local skills with same name).
*/
async #tieBreak(candidates) {
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const deduped = await this.#dedupeCanonicalCandidates(candidates);
if (deduped.length === 1) return deduped[0];
const SOURCE_PRIORITY = { local: 0, managed: 1, external: 2 };
const sorted = [...deduped].sort((a, b) => {
const aPri = SOURCE_PRIORITY[a.source.type] ?? 99;
const bPri = SOURCE_PRIORITY[b.source.type] ?? 99;
if (aPri !== bPri) return aPri - bPri;
return a.path.localeCompare(b.path);
});
const winner = sorted[0];
const runnerUp = sorted[1];
if (winner.source.type === runnerUp.source.type) {
const paths = sorted.filter((s) => s.source.type === winner.source.type).map((s) => `"${s.path}"`).join(", ");
throw new Error(
`[WorkspaceSkills] Cannot resolve skill "${winner.name}": multiple ${winner.source.type} skills found at ${paths}. Rename one or move it to a different source type.`
);
}
console.warn(
`[WorkspaceSkills] Multiple skills named "${winner.name}" found. Using "${winner.path}" (source: ${winner.source.type}). Other candidates: ${sorted.slice(1).map((s) => `"${s.path}" (${s.source.type})`).join(", ")}`
);
return winner;
}
async refresh() {
for (const candidates of this.#skills.values()) {
for (const skill of candidates) {
await this.#removeSkillFromIndex(skill);
}
}
this.#skills.clear();
this.#initialized = false;
this.#initPromise = null;
await this.#discoverSkills();
this.#initialized = true;
}
async maybeRefresh(context) {
await this.#ensureInitialized();
const currentPaths = await this.#resolvePaths(context);
const pathsChanged = !this.#arePathsEqual(this.#resolvedPaths, currentPaths);
if (pathsChanged) {
this.#resolvedPaths = currentPaths;
await this.refresh();
return;
}
const isStale = await this.#isSkillsPathStale();
if (isStale) {
await this.refresh();
}
}
async addSkill(skillPath) {
await this.#ensureInitialized();
let skillFilePath;
let dirName;
if (isSkillFilePath(skillPath)) {
skillFilePath = skillPath;
dirName = splitPathSegments(this.#getParentPath(skillPath)).pop() || "unknown";
} else {
skillFilePath = this.#joinPath(skillPath, "SKILL.md");
dirName = splitPathSegments(skillPath).pop() || "unknown";
}
const source = this.#inferSource(skillPath);
const skill = await this.#parseSkillFile(skillFilePath, dirName, source);
const candidates = this.#skills.get(skill.name) ?? [];
const existingIdx = candidates.findIndex((s) => s.path === skill.path);
if (existingIdx >= 0) {
await this.#removeSkillFromIndex(candidates[existingIdx]);
candidates[existingIdx] = skill;
} else {
candidates.push(skill);
}
this.#skills.set(skill.name, candidates);
await this.#indexSkill(skill);
this.#lastDiscoveryTime = Date.now();
}
async removeSkill(skillName) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
if (!skill) return;
await this.#removeSkillFromIndex(skill);
const candidates = this.#skills.get(skill.name);
if (candidates) {
const idx = candidates.findIndex((s) => s.path === skill.path);
if (idx >= 0) candidates.splice(idx, 1);
if (candidates.length === 0) {
this.#skills.delete(skill.name);
}
}
this.#lastDiscoveryTime = Date.now();
}
/**
* Resolve skills paths from the resolver (static array or function).
*/
async #resolvePaths(context) {
if (Array.isArray(this.#skillsResolver)) {
return this.#skillsResolver;
}
return this.#skillsResolver(context ?? {});
}
/**
* Compare two path arrays for equality (order-independent).
*/
#arePathsEqual(a, b) {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((path3, i) => path3 === sortedB[i]);
}
// ===========================================================================
// Search
// ===========================================================================
async search(query, options = {}) {
await this.#ensureInitialized();
if (!this.#searchEngine) {
return this.#simpleSearch(query, options);
}
const { topK = 5, minScore, skillNames, includeReferences = true, mode } = options;
const totalIndexedDocuments = [...this.#skills.values()].reduce(
(count, candidates) => count + candidates.reduce((skillCount, skill) => skillCount + 1 + skill.references.length, 0),
0
);
const expandedTopK = Math.max(skillNames ? topK * 3 : topK, totalIndexedDocuments);
const searchResults = await this.#searchEngine.search(query, {
topK: expandedTopK,
minScore,
mode
});
const results = [];
const seenCanonicalSources = /* @__PURE__ */ new Set();
for (const result of searchResults) {
const skillPath = result.metadata?.skillPath;
const source = result.metadata?.source;
if (!skillPath || !source) continue;
const matchedSkill = this.#resolveByPath(skillPath);
if (!matchedSkill) continue;
const skill = await this.#resolveByName(matchedSkill.name) ?? matchedSkill;
if (skillNames && !skillNames.includes(skill.name)) {
continue;
}
if (!includeReferences && source !== "SKILL.md") {
continue;
}
const canonicalSourceKey = `${skill.path}:${source}`;
if (seenCanonicalSources.has(canonicalSourceKey)) {
continue;
}
seenCanonicalSources.add(canonicalSourceKey);
results.push({
skillName: skill.name,
skillPath: skill.path,
source,
content: result.content,
score: result.score,
lineRange: result.lineRange,
scoreDetails: result.scoreDetails
});
if (results.length >= topK) break;
}
return results;
}
// ===========================================================================
// Single-item Accessors
// ===========================================================================
async getReference(skillName, referencePath) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
if (!skill) return null;
const safeRefPath = this.#assertRelativePath(referencePath, "reference");
const refFilePath = this.#joinPath(skill.path, safeRefPath);
if (!await this.#source.exists(refFilePath)) {
return null;
}
try {
const content = await this.#source.readFile(refFilePath);
return typeof content === "string" ? content : content.toString("utf-8");
} catch {
return null;
}
}
async getScript(skillName, scriptPath) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
if (!skill) return null;
const safeScriptPath = this.#assertRelativePath(scriptPath, "script");
const scriptFilePath = this.#joinPath(skill.path, safeScriptPath);
if (!await this.#source.exists(scriptFilePath)) {
return null;
}
try {
const content = await this.#source.readFile(scriptFilePath);
return typeof content === "string" ? content : content.toString("utf-8");
} catch {
return null;
}
}
async getAsset(skillName, assetPath) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
if (!skill) return null;
const safeAssetPath = this.#assertRelativePath(assetPath, "asset");
const assetFilePath = this.#joinPath(skill.path, safeAssetPath);
if (!await this.#source.exists(assetFilePath)) {
return null;
}
try {
const content = await this.#source.readFile(assetFilePath);
return typeof content === "string" ? Buffer.from(content, "utf-8") : content;
} catch {
return null;
}
}
// ===========================================================================
// Listing Accessors
// ===========================================================================
async listReferences(skillName) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
return skill?.references ?? [];
}
async listScripts(skillName) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
return skill?.scripts ?? [];
}
async listAssets(skillName) {
await this.#ensureInitialized();
const skill = await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName);
return skill?.assets ?? [];
}
// ===========================================================================
// Private Methods
// ===========================================================================
/**
* Ensure skills have been discovered.
* Uses a promise to prevent concurrent discovery.
*/
async #ensureInitialized() {
if (this.#initialized) {
return;
}
if (this.#initPromise) {
await this.#initPromise;
return;
}
this.#initPromise = (async () => {
try {
if (this.#resolvedPaths.length === 0) {
this.#resolvedPaths = await this.#resolvePaths();
}
await this.#discoverSkills();
this.#initialized = true;
} finally {
this.#initPromise = null;
}
})();
await this.#initPromise;
}
/**
* Add a skill to the candidates map, keyed by name.
* Replaces an existing entry at the same path (update case), otherwise appends.
*/
#addToSkillsMap(skill) {
const candidates = this.#skills.get(skill.name) ?? [];
const idx = candidates.findIndex((s) => s.path === skill.path);
if (idx >= 0) {
candidates[idx] = skill;
} else {
candidates.push(skill);
}
this.#skills.set(skill.name, candidates);
}
/**
* Discover skills from all skills paths.
* Uses currently resolved paths (must be set before calling).
*
* Paths can be plain directories, glob patterns, or direct
* skill references (e.g., '/skills/my-skill/SKILL.md').
*
* Uses resolvePathPattern for unified glob resolution. File matches
* pointing to SKILL.md are loaded directly; directory matches are
* tried as direct skills first, then scanned for subdirectories.
*/
async #discoverSkills() {
this.#globDirCache.clear();
this.#globResolveTimes.clear();
const readdir2 = async (dir) => {
const entries = await this.#source.readdir(dir);
return entries.map((e) => ({ name: e.name, type: e.type, isSymlink: e.isSymlink }));
};
for (const rawSkillsPath of this.#resolvedPaths) {
const skillsPath = rawSkillsPath.length > 1 && rawSkillsPath.endsWith("/") ? rawSkillsPath.slice(0, -1) : rawSkillsPath;
const source = this.#determineSource(skillsPath);
if (isGlobPattern(skillsPath)) {
const resolved = await resolvePathPattern(skillsPath, readdir2, { dot: true, maxDepth: 4 });
const dirs = /* @__PURE__ */ new Set();
for (const entry of resolved) {
if (entry.type === "directory") {
dirs.add(entry.path);
} else {
dirs.add(this.#getParentPath(entry.path));
}
}
this.#globDirCache.set(skillsPath, [...dirs]);
this.#globResolveTimes.set(skillsPath, Date.now());
const results = await Promise.allSettled(
resolved.map(async (entry) => {
if (entry.type === "file") {
await this.#discoverDirectSkill(entry.path, source);
} else {
const isDirect = await this.#discoverDirectSkill(entry.path, source);
if (!isDirect) {
await this.#discoverSkillsInPath(entry.path, source);
}
}
})
);
for (const [index, result] of results.entries()) {
const entry = resolved[index];
if (entry && result.status === "rejected") {
const error = result.reason;
if (error instanceof Error) {
console.error(`[WorkspaceSkills] Failed to load skill from ${entry.path}:`, error.message);
}
}
}
} else {
const isDirect = await this.#discoverDirectSkill(skillsPath, source);
if (!isDirect) {
await this.#discoverSkillsInPath(skillsPath, source);
}
}
}
this.#lastDiscoveryTime = Date.now();
}
/**
* Discover skills in a single path
*/
async #discoverSkillsInPath(skillsPath, source) {
try {
if (!await this.#source.exists(skillsPath)) {
return;
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
let hint = "";
if (skillsPath.startsWith("/") && msg.includes("Permission denied")) {
const relativePath = skillsPath.slice(1);
try {
if (await this.#source.exists(relativePath)) {
hint = ` (did you mean to use the relative path "${relativePath}"?)`;
}
} catch {
}
}
console.warn(`[WorkspaceSkills] Cannot access skills path "${skillsPath}": ${msg}${hint}`);
return;
}
try {
const entries = await this.#source.readdir(skillsPath);
const results = await Promise.allSettled(
entries.filter((entry) => entry.type === "directory").map(async (entry) => {
const entryPath = this.#joinPath(skillsPath, entry.name);
const skillFilePath = this.#joinPath(entryPath, "SKILL.md");
if (await this.#source.exists(skillFilePath)) {
const skill = await this.#parseSkillFile(skillFilePath, entry.name, source);
return skill;
}
return null;
})
);
for (const result of results) {
if (result.status === "fulfilled" && result.value) {
this.#addToSkillsMap(result.value);
await this.#indexSkill(result.value);
} else if (result.status === "rejected") {
const error = result.reason;
if (error instanceof Error) {
console.error(`[WorkspaceSkills] Failed to load skill from ${skillsPath}:`, error.message);
}
}
}
} catch (error) {
if (error instanceof Error) {
console.error(`[WorkspaceSkills] Failed to scan skills directory ${skillsPath}:`, error.message);
}
}
}
/**
* Attempt to discover a skill from a direct path reference.
*
* Handles two cases:
* - Path ends with `/SKILL.md` → parse directly, extract dirName from parent
* - Path is a directory containing `SKILL.md` → parse it as a single skill
*
* Returns `true` if the path was a direct skill reference (skip subdirectory scan),
* `false` to fall through to the normal subdirectory scan.
*/
async #discoverDirectSkill(skillsPath, source) {
try {
if (isSkillFilePath(skillsPath)) {
if (!await this.#source.exists(skillsPath)) {
return true;
}
const skillDir = this.#getParentPath(skillsPath);
const dirName = splitPathSegments(skillDir).pop() || skillDir;
try {
const skill = await this.#parseSkillFile(skillsPath, dirName, source);
this.#addToSkillsMap(skill);
await this.#indexSkill(skill);
} catch (error) {
if (error instanceof Error) {
console.error(`[WorkspaceSkills] Failed to load skill from ${skillsPath}:`, error.message);
}
}
return true;
}
if (await this.#source.exists(skillsPath)) {
const skillFilePath = this.#joinPath(skillsPath, "SKILL.md");
if (await this.#source.exists(skillFilePath)) {
const dirName = splitPathSegments(skillsPath).pop() || skillsPath;
try {
const skill = await this.#parseSkillFile(skillFilePath, dirName, source);
this.#addToSkillsMap(skill);
await this.#indexSkill(skill);
} catch (error) {
if (error instanceof Error) {
console.error(`[WorkspaceSkills] Failed to load skill from ${skillFilePath}:`, error.message);
}
}
return true;
}
}
return false;
} catch {
return false;
}
}
/**
* Check if any skills path directory has been modified since last discovery.
* Compares directory mtime to lastDiscoveryTime.
* For glob patterns, checks the walk root and expanded directories.
*/
async #isSkillsPathStale() {
if (this.#lastDiscoveryTime === 0) {
return true;
}
if (Date.now() - this.#lastDiscoveryTime < _WorkspaceSkillsImpl.STALENESS_CHECK_COOLDOWN) {
return false;
}
for (const skillsPath of this.#resolvedPaths) {
let pathsToCheck;
if (isGlobPattern(skillsPath)) {
const now = Date.now();
const lastResolved = this.#globResolveTimes.get(skillsPath) ?? 0;
if (now - lastResolved > _WorkspaceSkillsImpl.GLOB_RESOLVE_INTERVAL || !this.#globDirCache.has(skillsPath)) {
const readdir2 = async (dir) => {
const entries = await this.#source.readdir(dir);
return entries.map((e) => ({ name: e.name, type: e.type, isSymlink: e.isSymlink }));
};
const resolved = await resolvePathPattern(skillsPath, readdir2, { dot: true, maxDepth: 4 });
const dirs = /* @__PURE__ */ new Set();
for (const entry of resolved) {
if (entry.type === "directory") {
dirs.add(entry.path);
} else {
dirs.add(this.#getParentPath(entry.path));
}
}
const dirList = [...dirs];
this.#globDirCache.set(skillsPath, dirList);
this.#globResolveTimes.set(skillsPath, now);
}
pathsToCheck = this.#globDirCache.get(skillsPath) ?? [];
} else {
pathsToCheck = [skillsPath];
}
for (const pathToCheck of pathsToCheck) {
try {
const stat3 = await this.#source.stat(pathToCheck);
const mtime = stat3.modifiedAt.getTime();
if (mtime > this.#lastDiscoveryTime) {
return true;
}
if (stat3.type !== "directory") {
continue;
}
if (this.#checkSkillFileMtime) {
const directSkillFilePath = this.#joinPath(pathToCheck, "SKILL.md");
try {
const directSkillFileStat = await this.#source.stat(directSkillFilePath);
if (directSkillFileStat.type === "file" && directSkillFileStat.modifiedAt.getTime() > this.#lastDiscoveryTime) {
return true;
}
} catch {
}
}
const entries = await this.#source.readdir(pathToCheck);
const dirEntries = entries.filter((entry) => entry.type === "directory");
if (dirEntries.length > 0) {
const statResults = await Promise.all(
dirEntries.map(async (entry) => {
const entryPath = this.#joinPath(pathToCheck, entry.name);
try {
const entryStat = await this.#source.stat(entryPath);
if (entryStat.modifiedAt.getTime() > this.#lastDiscoveryTime) {
return true;
}
if (this.#checkSkillFileMtime) {
const skillFilePath = this.#joinPath(entryPath, "SKILL.md");
try {
const skillFileStat = await this.#source.stat(skillFilePath);
return skillFileStat.type === "file" && skillFileStat.modifiedAt.getTime() > this.#lastDiscoveryTime;
} catch {
}
}
} catch {
}
return false;
})
);
if (statResults.some((stale) => stale)) {
return true;
}
}
} catch {
continue;
}
}
}
return false;
}
/**
* Parse a SKILL.md file
*/
async #parseSkillFile(filePath, dirName, source) {
const rawContent = await this.#source.readFile(filePath);
const content = typeof rawContent === "string" ? rawContent : rawContent.toString("utf-8");
const parsed = matter(content);
const frontmatter = parsed.data;
const body = parsed.content.trim();
const skillPath = this.#getParentPath(filePath);
const metadata = {
name: frontmatter.name,
path: skillPath,
description: frontmatter.description,
license: frontmatter.license,
compatibility: frontmatter.compatibility,
"user-invocable": frontmatter["user-invocable"],
metadata: frontmatter.metadata
};
if (this.#validateOnLoad) {
const validation = this.#validateSkillMetadata(metadata, dirName, body);
if (!validation.valid) {
throw new Error(`Invalid skill metadata in ${filePath}:
${validation.errors.