Files
memind/skills-registry.mjs
T
john 0c1c0c3b48
Memind CI / Test, build, and release guards (push) Has been cancelled
feat: add pluggable Excel analyst report guard
2026-07-17 12:05:51 +08:00

260 lines
8.3 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_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
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,
[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);
}
}
}
export function syncSkillsToWorkspace({
h5Root,
publishDir,
skillMap,
catalog,
user,
publicBaseUrl,
}) {
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);
}
}
}