UNPKG

@mastra/core

Version:
1,441 lines (1,440 loc) 52.5 kB
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(/[\\/]+/); }