UNPKG

@mastra/core

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