Files
memind/skills-registry.mjs
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

335 lines
11 KiB
JavaScript

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_TEMPLATE_SKILL_PREFIX = 'page-template-';
export function isPageTemplateSkill(name) {
return String(name ?? '').startsWith(PAGE_TEMPLATE_SKILL_PREFIX);
}
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 AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development';
export const DEFAULT_USER_SKILLS = {
web: true,
search: true,
'search-enhanced': true,
[EXCEL_ANALYST_SKILL_NAME]: false,
'schedule-assistant': true,
'scheduled-task-automation': 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,
[AIDER_DEVELOPMENT_SKILL_NAME]: false,
[PUBLISH_SKILL_NAME]: false,
'page-template-travel': false,
'page-template-campaign': false,
'page-template-survey': false,
'page-template-restaurant': false,
'page-template-portfolio': false,
'page-template-event': false,
'page-template-course': false,
'page-template-realestate': false,
'page-template-blog': false,
'page-template-resume': false,
'page-template-wedding': false,
'page-template-fitness': false,
'page-template-product-grid': false,
'page-template-saas': false,
'page-template-hotel': false,
'page-template-agency': false,
'page-template-photography': false,
'page-template-podcast': false,
'page-template-charity': false,
'page-template-startup': 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,
[AIDER_DEVELOPMENT_SKILL_NAME]: true,
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);
}
}
});
}