@mastra/core
Version:
1,441 lines (1,440 loc) • 52.5 kB
JavaScript
const require_rolldown_runtime = require("./rolldown-runtime-uwYp4b74.cjs");
const require_errors = require("./errors-Bfv4VhVf.cjs");
let os = require("os");
os = require_rolldown_runtime.__toESM(os, 1);
let path = require("path");
path = require_rolldown_runtime.__toESM(path, 1);
let fs_promises = require("fs/promises");
fs_promises = require_rolldown_runtime.__toESM(fs_promises, 1);
let picomatch = require("picomatch");
picomatch = require_rolldown_runtime.__toESM(picomatch, 1);
let gray_matter = require("gray-matter");
gray_matter = require_rolldown_runtime.__toESM(gray_matter, 1);
//#region src/workspace/filesystem/fs-utils.ts
/**
* Shared filesystem utilities for LocalFilesystem and LocalSkillSource.
*
* These utilities provide consistent implementations for common fs operations.
*/
/**
* Expand a leading `~` or `~/` to the user's home directory.
* Shell commands handle this automatically, but Node.js path APIs do not.
*/
function expandTilde(p) {
if (p === "~") return os.homedir();
if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(os.homedir(), p.slice(2));
return p;
}
/**
* Check if an error is an ENOENT (file not found) error.
*/
function isEnoentError(error) {
return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
}
/**
* Check if an error is an EEXIST (file exists) error.
*/
function isEexistError(error) {
return error !== null && typeof error === "object" && "code" in error && error.code === "EEXIST";
}
const MIME_TYPES = {
txt: "text/plain",
html: "text/html",
htm: "text/html",
css: "text/css",
csv: "text/csv",
md: "text/markdown",
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",
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",
toml: "text/toml",
ini: "text/plain",
env: "text/plain",
sql: "text/x-sql",
graphql: "application/graphql",
gql: "application/graphql",
vue: "text/x-vue",
svelte: "text/x-svelte",
scss: "text/x-scss",
sass: "text/x-sass",
less: "text/x-less",
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",
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",
pdf: "application/pdf",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
flac: "audio/flac",
m4a: "audio/mp4",
aac: "audio/aac",
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
avi: "video/x-msvideo",
mkv: "video/x-matroska",
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",
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",
dmg: "application/x-apple-diskimage",
iso: "application/x-iso9660-image",
deb: "application/vnd.debian.binary-package",
rpm: "application/x-rpm",
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",
ttf: "font/ttf",
otf: "font/otf",
woff: "font/woff",
woff2: "font/woff2",
wasm: "application/wasm",
class: "application/java-vm",
pyc: "application/x-python-code"
};
/**
* Get MIME type for a filename based on extension.
*/
function getMimeType(filename) {
const ext = path.extname(filename).slice(1).toLowerCase();
return MIME_TYPES[ext] ?? "application/octet-stream";
}
/**
* Extensions that should be treated as text files.
*/
const 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"
]);
/**
* Check if a file should be treated as text based on extension.
*/
function isTextFile(filename) {
const ext = path.extname(filename).toLowerCase();
return TEXT_EXTENSIONS.has(ext);
}
/**
* Resolve a path against a base directory.
*
* - Tilde (`~`) is expanded to the user's home directory.
* - Absolute paths are normalized and returned as-is.
* - Relative paths (including `../`) are resolved against `basePath`.
*
* @param basePath - The absolute base path to resolve against
* @param filePath - The path to resolve
* @returns The absolute resolved path
*/
function resolveToBasePath(basePath, filePath) {
const expanded = expandTilde(filePath);
if (path.isAbsolute(expanded)) return path.normalize(expanded);
return path.resolve(basePath, expanded);
}
/**
* Check if a path exists.
* Never throws - returns false on any error.
*
* @param absolutePath - The absolute path to check
* @returns true if path exists and is accessible
*/
async function fsExists(absolutePath) {
try {
await fs_promises.access(absolutePath);
return true;
} catch {
return false;
}
}
/**
* Get file/directory stats.
* Throws FileNotFoundError if path doesn't exist.
*
* @param absolutePath - The absolute path to stat
* @param userPath - The user-facing path for error messages
* @returns File stat information
* @throws {FileNotFoundError} if path doesn't exist
*/
async function fsStat(absolutePath, userPath) {
try {
const stats = await fs_promises.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 require_errors.FileNotFoundError(userPath);
throw error;
}
}
//#endregion
//#region src/workspace/glob.ts
/**
* Glob Pattern Utilities
*
* Shared glob pattern matching for workspace operations.
* Uses picomatch for battle-tested glob support including
* brace expansion, character classes, negation, and `**`.
*/
/** Characters that indicate a glob pattern (not a plain path) */
const GLOB_CHARS = /[*?{}[\]]/;
/**
* Check if a string contains glob metacharacters.
*
* @example
* isGlobPattern('/docs') // false
* isGlobPattern('/docs/**\/*.md') // true
* isGlobPattern('*.ts') // true
* isGlobPattern('/src/{a,b}') // true
*/
function isGlobPattern(input) {
return GLOB_CHARS.test(input);
}
/**
* Extract the static directory prefix before the first glob metacharacter.
* Returns the deepest non-glob ancestor directory.
*
* @example
* extractGlobBase('docs/**\/*.md') // 'docs'
* extractGlobBase('**\/*.md') // '.'
* extractGlobBase('src/*.ts') // 'src'
* extractGlobBase('exact/path') // 'exact/path'
*/
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);
}
/**
* Strip leading './' or '/' from a path for picomatch matching.
* picomatch does not match paths with these prefixes, so both
* patterns and test paths must be normalized before matching.
*
* This only affects matching — filesystem paths should keep their
* original form for correct resolution with contained/uncontained modes.
*/
function normalizeForMatch(input) {
if (input.startsWith("./")) return input.slice(2);
if (input.startsWith("/")) return input.slice(1);
return input;
}
/**
* Compile glob pattern(s) into a reusable matcher function.
* The matcher tests paths using workspace-style forward slashes.
*
* Automatically normalizes leading './' and '/' from both patterns
* and test paths, since picomatch does not match these prefixes.
*
* @example
* const match = createGlobMatcher('**\/*.ts');
* match('src/index.ts') // true
* match('src/style.css') // false
*
* const multi = createGlobMatcher(['**\/*.ts', '**\/*.tsx']);
* multi('App.tsx') // true
*/
function createGlobMatcher(patterns, options) {
const matcher = (0, picomatch.default)((Array.isArray(patterns) ? patterns : [patterns]).map(normalizeForMatch), {
posix: true,
dot: options?.dot ?? false
});
return (path) => matcher(normalizeForMatch(path));
}
/**
* One-off convenience: test if a path matches a glob pattern.
*
* For repeated matching against the same pattern, prefer createGlobMatcher()
* to compile once and reuse.
*
* @example
* matchGlob('src/index.ts', '**\/*.ts') // true
*/
function matchGlob(path, pattern, options) {
return createGlobMatcher(pattern, options)(path);
}
/**
* Walk a directory tree recursively, returning all entries (files and directories).
* Skips symlinked directories to prevent infinite loops.
*/
async function walkAll(readdir, dir, depth, maxDepth) {
if (depth >= maxDepth) return [];
try {
const entries = await readdir(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(readdir, fullPath, depth + 1, maxDepth));
}
return results;
} catch {
return [];
}
}
/**
* Resolve a path pattern to matching filesystem entries.
*
* Handles both plain paths and glob patterns consistently:
* - Plain paths: determines file vs directory via readdir probe, returns single entry
* - Glob patterns: walks from the glob base, matches both files and directories
*
* @example
* // Plain paths
* resolvePathPattern('/docs', readdir) // [{ path: '/docs', type: 'directory' }]
* resolvePathPattern('/docs/readme.md', readdir) // [{ path: '/docs/readme.md', type: 'file' }]
*
* // Glob patterns — matches files and directories
* resolvePathPattern('/docs/**\/*.md', readdir) // all .md files under /docs
* resolvePathPattern('**\/skills', readdir) // all directories (and files) named 'skills'
* resolvePathPattern('/skills/**', readdir) // everything under /skills
*/
async function resolvePathPattern(pattern, readdir, options) {
const maxDepth = options?.maxDepth ?? 10;
const normalized = pattern.length > 1 && pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
if (!isGlobPattern(normalized)) try {
await readdir(normalized);
return [{
path: normalized,
type: "directory"
}];
} catch {
return [{
path: normalized,
type: "file"
}];
}
const walkRoot = extractGlobBase(normalized);
const matcher = createGlobMatcher(normalized, { dot: options?.dot ?? false });
return (await walkAll(readdir, walkRoot, 0, maxDepth)).filter((entry) => matcher(entry.path));
}
//#endregion
//#region src/workspace/skills/schemas.ts
/**
* Validation for Skills following the Agent Skills specification.
* @see https://agentskills.io/specification
*
* This module uses plain validation functions instead of Zod to avoid
* version compatibility issues between Zod 3 and Zod 4.
*/
/**
* Recommended limits from the Agent Skills spec
*/
const 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,
/** Max characters for compatibility field */
MAX_COMPATIBILITY_LENGTH: 500
};
/**
* Validate skill name according to spec:
* - 1-64 characters
* - Lowercase letters, numbers, hyphens only
* - Must not start or end with hyphen
* - Must not contain consecutive hyphens
*
* @param name - The name to validate
* @returns Array of error messages (empty if valid)
*/
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;
}
/**
* Validate skill description according to spec:
* - 1-1024 characters
* - Cannot be empty or only whitespace
*
* @param description - The description to validate
* @returns Array of error messages (empty if valid)
*/
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;
}
/**
* Validate skill license (optional string).
*
* @param license - The license to validate
* @returns Array of error messages (empty if valid)
*/
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;
}
/**
* Validate skill compatibility notes (optional).
* Accepts string or any JSON-serializable value for flexibility with external skills.
*
* @param compatibility - The compatibility value to validate
* @returns Array of error messages (empty if valid)
*/
function validateSkillCompatibility(_compatibility) {
return [];
}
/**
* Validate skill metadata field (optional Record<string, unknown>).
* Accepts any values (not just strings) for flexibility with external skills.
*
* @param metadata - The metadata object to validate
* @returns Array of error messages (empty if valid)
*/
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}`];
}
/**
* Rough token estimate (words * 1.3)
* This is a simple heuristic; actual token counts vary by model
*/
function estimateTokens(text) {
const words = text.split(/\s+/).filter(Boolean).length;
return Math.ceil(words * 1.3);
}
/**
* Count lines in text
*/
function countLines(text) {
return text.split("\n").length;
}
/**
* Validate skill metadata with optional content warnings.
*
* @param metadata - The skill metadata to validate
* @param dirName - The directory name (must match skill name)
* @param instructions - Optional instructions content for token/line warnings
* @returns Validation result with errors and warnings
*
* @example
* ```typescript
* const result = validateSkillMetadata(
* { name: 'my-skill', description: 'A helpful skill' },
* 'my-skill',
* '# Instructions\n...'
* );
*
* if (!result.valid) {
* console.error('Validation errors:', result.errors);
* }
* if (result.warnings.length > 0) {
* console.warn('Warnings:', result.warnings);
* }
* ```
*/
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(data.compatibility));
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
};
}
//#endregion
//#region src/workspace/skills/local-skill-source.ts
/**
* LocalSkillSource - Read-only skill source backed by local filesystem.
*
* Uses Node.js fs/promises to read skills directly from disk.
* This allows skills to be loaded without requiring a full WorkspaceFilesystem.
*
* @example
* ```typescript
* const source = new LocalSkillSource({
* basePath: process.cwd(),
* });
*
* // skills paths are relative to basePath
* const skillsImpl = new WorkspaceSkillsImpl({
* source,
* skills: ['./skills', './node_modules/@company/skills'],
* });
* ```
*/
/**
* Read-only skill source that loads skills from the local filesystem.
*
* Unlike WorkspaceFilesystem, this doesn't provide write operations.
* Skills loaded from this source are read-only.
*/
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 fs_promises.readFile(resolved);
if (isTextFile(skillPath)) return content.toString("utf-8");
return content;
}
async readdir(skillPath) {
const resolved = this.#resolvePath(skillPath);
const entries = await fs_promises.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 {
type = (await fs_promises.stat(entryPath)).isDirectory() ? "directory" : "file";
} catch {
type = "file";
}
return {
name: entry.name,
type,
isSymlink: isSymlink || void 0
};
}));
}
async realpath(skillPath) {
return fs_promises.realpath(this.#resolvePath(skillPath));
}
};
//#endregion
//#region src/workspace/skills/workspace-skills.ts
/**
* WorkspaceSkills - Skills implementation.
*
* Provides discovery and search operations for skills stored
* in skills paths. All operations are async.
*/
/**
* Implementation of WorkspaceSkills interface.
*/
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;
static STALENESS_CHECK_COOLDOWN = 2e3;
constructor(config) {
this.#source = config.source;
this.#skillsResolver = config.skills;
this.#searchEngine = config.searchEngine;
this.#validateOnLoad = config.validateOnLoad ?? true;
this.#checkSkillFileMtime = config.checkSkillFileMtime ?? false;
}
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;
}
/**
* 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);
if (!this.#arePathsEqual(this.#resolvedPaths, currentPaths)) {
this.#resolvedPaths = currentPaths;
await this.refresh();
return;
}
if (await this.#isSkillsPathStale()) 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((path, i) => path === sortedB[i]);
}
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;
}
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;
}
}
async listReferences(skillName) {
await this.#ensureInitialized();
return (await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName))?.references ?? [];
}
async listScripts(skillName) {
await this.#ensureInitialized();
return (await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName))?.scripts ?? [];
}
async listAssets(skillName) {
await this.#ensureInitialized();
return (await this.#resolveByName(skillName) ?? this.#resolveByPath(skillName))?.assets ?? [];
}
/**
* 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 readdir = async (dir) => {
return (await this.#source.readdir(dir)).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, readdir, {
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 if (!await this.#discoverDirectSkill(entry.path, source)) 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 if (!await this.#discoverDirectSkill(skillsPath, source)) 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)) return await this.#parseSkillFile(skillFilePath, entry.name, source);
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();
if (now - (this.#globResolveTimes.get(skillsPath) ?? 0) > WorkspaceSkillsImpl.GLOB_RESOLVE_INTERVAL || !this.#globDirCache.has(skillsPath)) {
const readdir = async (dir) => {
return (await this.#source.readdir(dir)).map((e) => ({
name: e.name,
type: e.type,
isSymlink: e.isSymlink
}));
};
const resolved = await resolvePathPattern(skillsPath, readdir, {
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 stat = await this.#source.stat(pathToCheck);
if (stat.modifiedAt.getTime() > this.#lastDiscoveryTime) return true;
if (stat.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 dirEntries = (await this.#source.readdir(pathToCheck)).filter((entry) => entry.type === "directory");
if (dirEntries.length > 0) {
if ((await Promise.all(dirEntries.map(async (entry) => {
const entryPath = this.#joinPath(pathToCheck, entry.name);
try {
if ((await this.#source.stat(entryPath)).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;
}))).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 parsed = (0, gray_matter.default)(typeof rawContent === "string" ? rawContent : rawContent.toString("utf-8"));
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}:\n${validation.errors.join("\n")}`);
}
const [references, scripts, assets] = await Promise.all([
this.#discoverFilesInSubdir(skillPath, "references"),
this.#discoverFilesInSubdir(skillPath, "scripts"),
this.#discoverFilesInSubdir(skillPath, "assets")
]);
const indexableContent = await this.#buildIndexableContent(body, skillPath, references);
return {
...metadata,
instructions: body,
source,
references,
scripts,
assets,
indexableContent
};
}
/**
* Validate skill metadata (delegates to shared validation function)
*/
#validateSkillMetadata(metadata, dirName, instructions) {
const result = validateSkillMetadata(metadata, dirName, instructions);
if (result.warnings.length > 0) for (const warning of result.warnings) console.warn(`[WorkspaceSkills] ${metadata.name}: ${warning}`);
return result;
}
/**
* Discover files in a subdirectory of a skill (references/, scripts/, assets/)
*/
async #discoverFilesInSubdir(skillPath, subdir) {
const subdirPath = this.#joinPath(skillPath, subdir);
const files = [];
if (!await this.#source.exists(subdirPath)) return files;
try {
await this.#walkDirectory(subdirPath, subdirPath, (relativePath) => {
files.push(relativePath);
});
} catch {}
return files;
}
/**
* Walk a directory recursively and call callback for each file.
* Limited to maxDepth (default 20) to prevent stack overflow on deep hierarchies.
*/
async #walkDirectory(basePath, dirPath, callback, depth = 0, maxDepth = 20) {
if (depth >= maxDepth) return;
const entries = await this.#source.readdir(dirPath);
for (const entry of entries) {
const entryPath = this.#joinPath(dirPath, entry.name);
if (entry.type === "directory" && !entry.isSymlink) await this.#walkDirectory(basePath, entryPath, callback, depth + 1, maxDepth);
else callback(entryPath.substring(basePath.length + 1));
}
}
/**
* Build indexable content from instructions and references
*/
async #buildIndexableContent(instructions, skillPath, references) {
const parts = [instructions];
const refContents = await Promise.all(references.map(async (refPath) => {
const fullPath = this.#joinPath(skillPath, "references", refPath);
try {
const rawContent = await this.#source.readFile(fullPath);
return typeof rawContent === "string" ? rawContent : rawContent.toString("utf-8");
} catch {
return null;
}
}));
for (const content of refContents) if (content !== null) parts.push(content);
return parts.join("\n\n");
}
/**
* Remove a skill's entries from the search index.
*/
async #removeSkillFromIndex(skill) {
if (!this.#searchEngine?.remove) return;
const ids = [`skill:${skill.path}:SKILL.md`, ...skill.references.map((r) => `skill:${skill.path}:${r}`)];
for (const id of ids) try {
await this.#searchEngine.remove(id);
} catch {}
}
/**
* Infer the ContentSource for a skill path by matching against resolved paths.
*/
#inferSource(skillPath) {
for (const rp of this.#resolvedPaths) if (skillPath === rp || skillPath.startsWith(rp + "/")) return this.#determineSource(rp);
return this.#determineSource(skillPath);
}
/**
* Index a skill for search
*/
async #indexSkill(skill) {
if (!this.#searchEngine) return;
await this.#searchEngine.index({
id: `skill:${skill.path}:SKILL.md`,
content: skill.instructions,
metadata: {
skillPath: skill.path,
source: "SKILL.md"
}
});
await Promise.all(skill.references.map(async (refPath) => {
const fullPath = this.#joinPath(skill.path, "references", refPath);
try {
const rawContent = await this.#source.readFile(fullPath);
const content = typeof rawContent === "string" ? rawContent : rawContent.toString("utf-8");
await this.#searchEngine.index({
id: `skill:${skill.path}:${refPath}`,
content,
metadata: {
skillPath: skill.path,
source: `references/${refPath}`
}
});
} catch {}
}));
}
/**
* Simple text search fallback when no search engine is configured
*/
async #simpleSearch(query, options) {
const { topK = 5, skillNames, includeReferences = true } = options;
const queryLower = query.toLowerCase();
const results = [];
for (const candidates of this.#skills.values()) {
const skill = await this.#tieBreak(candidates);
if (!skill) continue;
if (skillNames && !skillNames.includes(skill.name)) continue;
if (skill.instructions.toLowerCase().includes(queryLower)) results.push({
skillName: skill.name,
skillPath: skill.path,
source: "SKILL.md",
content: skill.instructions.substring(0, 200),
score: 1
});
if (includeReferences) for (const refPath of skill.references) {
if (results.length >= topK) break;
const content = await this.getReference(skill.name, `references/${refPath}`);
if (content && content.toLowerCase().includes(queryLower)) results.push({
skillName: skill.name,
skillPath: skill.path,
source: `references/${refPath}`,
content: content.substring(0, 200),
score: .8
});
}
if (results.length >= topK) break;
}
return results.slice(0, topK);
}
/**
* Determine the source type based on the path
*/
#determineSource(skillsPath) {
if (splitPathSegments(skillsPath).includes("node_modules")) return {
type: "external",
packagePath: skillsPath
};
const normalized = skillsPath.replace(/\\/g, "/");
if (normalized.includes("/.mastra/skills") || normalized.startsWith(".mastra/skills")) return {
type: "managed",
mastraPath: skillsPath
};
return {
type: "local",
projectPath: skillsPath
};
}
/**
* Join path segments (workspace paths use forward slashes)
*/
#joinPath(...segments) {
return segments.map((seg, i) => i === 0 ? stripTrailingSlashes(seg) : stripLeadingAndTrailingSlashes(seg)).filter(Boolean).join("/");
}
/**
* Validate and normalize a relative path to prevent directory traversal.
* Throws if the path contains traversal segments (..) or is absolute.
*/
#assertRelativePath(input, label) {
const normalized = input.replace(/\\/g, "/");
const segments = normalized.split("/").filter((seg) => Boolean(seg) && seg !== ".");
if (normalized.startsWith("/") || segments.some((seg) => seg === "..")) throw new Error(`Invalid ${label} path: ${input}`);
return segments.join("/");
}
/**
* Get parent path
*/
#getParentPath(path) {
const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
return lastSlash > 0 ? path.substring(0, lastSlash) : "/";
}
};
/**
* Split a path into segments, tolerating both POSIX (`/`) and Windows (`\`)
* separators. Workspace-internal paths use forward slashes, but consumer-supplied
* absolute paths (e.g. via `new Workspace({ skills: [...] })`) may use backslashes
* on Windows.
*/
function splitPathSegments(path) {
return path.split(/[\\/]+/);
}