import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensurePublishSkillInstalled, PUBLISH_SKILL_NAME, renderPublishSkill, resolvePublicBaseUrl, } from './user-publish.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst'; export const IMAGE_GENERATION_SKILL_NAME = 'image-generation'; export const DEFAULT_USER_SKILLS = { web: true, search: true, 'search-enhanced': false, [EXCEL_ANALYST_SKILL_NAME]: false, 'schedule-assistant': true, 'service-integration-smoke': true, 'form-builder': true, 'table-viewer': true, 'product-campaign-page': true, 'docx-generate': true, 'long-image-download': true, [IMAGE_GENERATION_SKILL_NAME]: true, [PAGE_DATA_COLLECT_SKILL_NAME]: true, [PUBLISH_SKILL_NAME]: false, }; /** Optional presets for admin role configuration (creator / developer). */ export const USER_ROLE_SKILL_PRESETS = { user: DEFAULT_USER_SKILLS, creator: { ...DEFAULT_USER_SKILLS, [PUBLISH_SKILL_NAME]: true, kanban: true, timeline: true, }, developer: { ...DEFAULT_USER_SKILLS, git: true, 'diff-viewer': true, 'code-playground': true, 'test-runner': true, }, }; export function parseSkillFrontmatter(content) { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return { name: null, description: '' }; const block = match[1]; const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? null; const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''; return { name, description }; } function parseManifestScalar(value) { const text = String(value ?? '').trim(); if (!text) return ''; if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { return text.slice(1, -1); } if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text); if (/^(?:true|false)$/i.test(text)) return text.toLowerCase() === 'true'; if (/^(?:null|~)$/i.test(text)) return null; return text; } /** Parse the small YAML subset used by platform skill manifests without adding a runtime dependency. */ export function parseSkillManifest(content) { const lines = String(content ?? '').split(/\r?\n/); const root = {}; const stack = [{ indent: -1, value: root }]; for (let index = 0; index < lines.length; index += 1) { const raw = lines[index]; const trimmed = raw.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const indent = raw.match(/^\s*/)?.[0]?.length ?? 0; while (stack.length > 1 && stack.at(-1).indent >= indent) stack.pop(); const parent = stack.at(-1).value; if (trimmed.startsWith('- ')) { if (!Array.isArray(parent)) throw new Error(`Invalid manifest list at line ${index + 1}`); parent.push(parseManifestScalar(trimmed.slice(2))); continue; } const separator = trimmed.indexOf(':'); if (separator <= 0 || Array.isArray(parent)) { throw new Error(`Invalid manifest entry at line ${index + 1}`); } const key = trimmed.slice(0, separator).trim(); const value = trimmed.slice(separator + 1).trim(); if (value) { parent[key] = parseManifestScalar(value); continue; } let nextTrimmed = ''; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { nextTrimmed = lines[cursor].trim(); if (nextTrimmed && !nextTrimmed.startsWith('#')) break; } const child = nextTrimmed.startsWith('- ') ? [] : {}; parent[key] = child; stack.push({ indent, value: child }); } return root; } export function listPlatformSkillCatalog(h5Root = __dirname) { const skillsRoot = path.join(h5Root, 'skills'); if (!fs.existsSync(skillsRoot)) return []; const catalog = []; for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillPath = path.join(skillsRoot, entry.name, 'SKILL.md'); if (!fs.existsSync(skillPath)) continue; const raw = fs.readFileSync(skillPath, 'utf8'); const meta = parseSkillFrontmatter(raw); const manifestPath = path.join(skillsRoot, entry.name, 'skill.yaml'); let manifest = null; if (fs.existsSync(manifestPath)) { try { manifest = parseSkillManifest(fs.readFileSync(manifestPath, 'utf8')); } catch { manifest = null; } } const name = manifest?.name || meta.name || entry.name; catalog.push({ name, dirName: entry.name, label: manifest?.label || name, description: manifest?.description || meta.description || '平台通用技能', version: manifest?.version ?? null, executors: Array.isArray(manifest?.executors) ? manifest.executors : ['goose'], manifest, category: 'platform', requiresPublish: name === PUBLISH_SKILL_NAME || name === PAGE_DATA_COLLECT_SKILL_NAME, }); } return catalog.sort((a, b) => a.name.localeCompare(b.name)); } export function catalogSkillNames(catalog) { return catalog.map((item) => item.name); } export function isValidSkillName(catalog, name) { return catalog.some((item) => item.name === name); } export function normalizeSkillPatch(catalog, patch) { const normalized = {}; for (const [key, value] of Object.entries(patch ?? {})) { if (!isValidSkillName(catalog, key)) continue; normalized[key] = Boolean(value); } return normalized; } export function resolveSkillMap(roleGrants, userOverrides, catalog) { const resolved = {}; for (const item of catalog) { const key = item.name; if (key in userOverrides) { resolved[key] = userOverrides[key]; } else if (key in roleGrants) { resolved[key] = roleGrants[key]; } else { resolved[key] = DEFAULT_USER_SKILLS[key] ?? false; } } return resolved; } export function grantedSkillNames(skillMap) { return Object.entries(skillMap) .filter(([, enabled]) => enabled) .map(([name]) => name); } export function applySkillGrantsToCapabilities(capabilities, skillMap) { const effective = { ...capabilities }; const enabled = grantedSkillNames(skillMap); if (enabled.length > 0) { effective.skills = true; } if (enabled.includes(PUBLISH_SKILL_NAME) || enabled.includes(PAGE_DATA_COLLECT_SKILL_NAME)) { effective.static_publish = true; } // Excel is intentionally a single MemindAdm skill switch. The internal // capability is derived here instead of appearing as a second admin toggle. effective.excel_analysis = enabled.includes(EXCEL_ANALYST_SKILL_NAME); return effective; } function copySkillTree(srcDir, destDir) { fs.mkdirSync(destDir, { recursive: true }); for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { const from = path.join(srcDir, entry.name); const to = path.join(destDir, entry.name); if (entry.isDirectory()) { copySkillTree(from, to); } else { fs.copyFileSync(from, to); } } } const SKILL_SYNC_LOCK_RETRY_MS = 25; const SKILL_SYNC_LOCK_TIMEOUT_MS = 10_000; const SKILL_SYNC_LOCK_STALE_MS = 60_000; const skillSyncWaitBuffer = new Int32Array(new SharedArrayBuffer(4)); function withWorkspaceSkillSyncLock(publishDir, callback) { const agentsRoot = path.join(publishDir, '.agents'); const lockDir = path.join(agentsRoot, '.skills-sync.lock'); fs.mkdirSync(agentsRoot, { recursive: true }); const startedAt = Date.now(); while (true) { try { fs.mkdirSync(lockDir); break; } catch (error) { if (error?.code !== 'EEXIST') throw error; try { const ageMs = Date.now() - fs.statSync(lockDir).mtimeMs; if (ageMs > SKILL_SYNC_LOCK_STALE_MS) { fs.rmSync(lockDir, { recursive: true, force: true }); continue; } } catch (statError) { if (statError?.code === 'ENOENT') continue; throw statError; } if (Date.now() - startedAt >= SKILL_SYNC_LOCK_TIMEOUT_MS) { throw new Error(`Timed out waiting for workspace skill sync lock: ${publishDir}`); } Atomics.wait(skillSyncWaitBuffer, 0, 0, SKILL_SYNC_LOCK_RETRY_MS); } } try { return callback(); } finally { fs.rmSync(lockDir, { recursive: true, force: true }); } } export function syncSkillsToWorkspace({ h5Root, publishDir, skillMap, catalog, user, publicBaseUrl, }) { return withWorkspaceSkillSyncLock(publishDir, () => { const enabled = new Set(grantedSkillNames(skillMap)); const platformNames = new Set(catalog.map((item) => item.name)); const agentsSkills = path.join(publishDir, '.agents', 'skills'); if (fs.existsSync(agentsSkills)) { for (const entry of fs.readdirSync(agentsSkills, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillName = entry.name; const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName); const resolvedName = catalogItem?.name ?? skillName; if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) { fs.rmSync(path.join(agentsSkills, entry.name), { recursive: true, force: true }); } } } for (const item of catalog) { if (!enabled.has(item.name)) continue; const srcDir = path.join(h5Root, 'skills', item.dirName); const destDir = path.join(agentsSkills, item.name); if (item.name === PUBLISH_SKILL_NAME && user) { ensurePublishSkillInstalled(publishDir, { slug: String(user.id).trim().toLowerCase(), username: user.username ? String(user.username).trim().toLowerCase() : undefined, publicBaseUrl, publishDir, }); continue; } if (fs.existsSync(srcDir)) { if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true }); copySkillTree(srcDir, destDir); } } }); }