normalize-skill.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. #!/usr/bin/env node
  2. /**
  3. * normalize-skill.mjs —— SKILL.md 规范化:把各仓库不同的 frontmatter 统一成 SkillHub 可检索格式
  4. *
  5. * 各源仓库现状(不统一):
  6. * - skill-storage: name + description + keywords(无触发词)
  7. * - skill-deliverable: slug + summary + tags(无 name/description)
  8. * - 其他: 各种变体
  9. *
  10. * SkillHub 检索依赖:frontmatter 的 name + description,且 description 内需含触发词。
  11. * 本模块负责:读入原始 SKILL.md → 输出统一格式(保留正文,补全 frontmatter)。
  12. */
  13. import fs from 'node:fs';
  14. import path from 'node:path';
  15. /** 解析 YAML frontmatter(轻量实现,只处理单行 key: value 与数组) */
  16. export function parseFrontmatter(content) {
  17. const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
  18. if (!m) return { fm: {}, body: content };
  19. const fm = {};
  20. const lines = m[1].split(/\r?\n/);
  21. let currentKey = null;
  22. for (const line of lines) {
  23. if (!line.trim()) continue;
  24. // 数组项
  25. if (/^\s*-\s+/.test(line) && currentKey) {
  26. const val = line.replace(/^\s*-\s+/, '').trim();
  27. if (!Array.isArray(fm[currentKey])) fm[currentKey] = [];
  28. fm[currentKey].push(val);
  29. continue;
  30. }
  31. // key: value
  32. const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
  33. if (kv) {
  34. currentKey = kv[1];
  35. let val = kv[2].trim();
  36. // 多行字符串(| 或 >)简化处理:取后续行拼接
  37. if (val === '|' || val === '>') {
  38. fm[currentKey] = '';
  39. continue;
  40. }
  41. // YAML 内联数组:[a, b, c]
  42. if (val.startsWith('[') && val.endsWith(']')) {
  43. fm[currentKey] = val
  44. .slice(1, -1)
  45. .split(',')
  46. .map(s => s.trim().replace(/^["']|["']$/g, ''))
  47. .filter(Boolean);
  48. continue;
  49. }
  50. // 去引号
  51. if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
  52. val = val.slice(1, -1);
  53. }
  54. fm[currentKey] = val;
  55. } else if (currentKey && typeof fm[currentKey] === 'string') {
  56. // 多行描述续行
  57. fm[currentKey] = (fm[currentKey] + ' ' + line.trim()).trim();
  58. }
  59. }
  60. return { fm, body: content.slice(m[0].length) };
  61. }
  62. /** 从各字段中收集关键词 */
  63. function collectKeywords(fm) {
  64. const out = new Set();
  65. // 品牌词、层级词、通用词不入触发词
  66. const BLOCK = /^(FmodeAgent|FmodeCode|Hermes(\s*Agent)?|Claude(\s*Code)?|Codex|GPT|DeepSeek|Agent|未来飞马|智能体技能|超级技能|平台基础设施|系统级|服务级|应用级|系统|服务|应用|skill|技能)$/i;
  67. const push = (v) => {
  68. if (!v) return;
  69. const arr = Array.isArray(v) ? v : String(v).split(/[,,、\s]+/);
  70. for (const item of arr) {
  71. const t = String(item).trim();
  72. // 过滤品牌名、通用词、过长项
  73. if (!t || t.length > 20) continue;
  74. if (BLOCK.test(t)) continue;
  75. out.add(t);
  76. }
  77. };
  78. push(fm.keywords);
  79. push(fm.tags);
  80. push(fm.trigger);
  81. push(fm.triggers);
  82. return [...out];
  83. }
  84. /** 从正文中提取一级/二级标题作为场景线索 */
  85. function collectHeadings(body) {
  86. const out = [];
  87. const re = /^#{1,2}\s+(.+)$/gm;
  88. let m;
  89. while ((m = re.exec(body)) !== null) {
  90. const h = m[1].trim();
  91. if (h.length > 1 && h.length < 30) out.push(h);
  92. if (out.length >= 8) break;
  93. }
  94. return out;
  95. }
  96. /** 判断 description 里是否已有触发词标记 */
  97. function hasTrigger(desc) {
  98. return /触发词[::]/.test(desc || '');
  99. }
  100. /**
  101. * 规范化单个技能目录下的 SKILL.md
  102. * @returns {{ok:boolean, issues:string[], output?:string, meta?:object}}
  103. */
  104. export function normalizeSkill(skillDir, skillName) {
  105. const skillMd = path.join(skillDir, 'SKILL.md');
  106. if (!fs.existsSync(skillMd)) {
  107. return { ok: false, issues: ['缺少 SKILL.md'] };
  108. }
  109. const raw = fs.readFileSync(skillMd, 'utf8');
  110. const { fm, body } = parseFrontmatter(raw);
  111. const issues = [];
  112. // name:优先 name,其次 slug,最后用目录名
  113. const name = fm.name || fm.slug || skillName;
  114. // description:优先 description,其次 summary,最后从正文首段提取
  115. let description = fm.description || fm.summary || '';
  116. if (!description) {
  117. const firstPara = body
  118. .split(/\n\n+/)
  119. .map(s => s.trim())
  120. .find(s => s && !s.startsWith('#') && !s.startsWith('|') && !s.startsWith('>'));
  121. description = firstPara ? firstPara.slice(0, 200) : `${skillName} 技能`;
  122. }
  123. // 触发词:已有则保留;没有则自动生成并追加
  124. let triggers = [];
  125. if (hasTrigger(description)) {
  126. const tm = /触发词[::]\s*(.+?)(?:。|$)/.exec(description);
  127. if (tm) triggers = tm[1].split(/[,,、\s]+/).filter(Boolean);
  128. } else {
  129. triggers = collectKeywords(fm);
  130. if (triggers.length < 3) {
  131. // 关键词不足时,用标题补
  132. const heads = collectHeadings(body);
  133. for (const h of heads) {
  134. if (triggers.length >= 6) break;
  135. if (!triggers.includes(h)) triggers.push(h);
  136. }
  137. }
  138. if (triggers.length) {
  139. description = description.replace(/\s+$/, '') + ` 触发词:${triggers.join('、')}`;
  140. } else {
  141. issues.push('无法生成触发词(keywords/tags/标题均不足)');
  142. }
  143. }
  144. // 组装统一 frontmatter
  145. const outFm = {
  146. name,
  147. description: description.replace(/\s+/g, ' ').trim(),
  148. version: fm.version || fm.schema_version || '1.0.0',
  149. license: fm.license || 'MPL-2.0',
  150. author: 'Fmode (未来飞马)',
  151. };
  152. if (triggers.length) outFm.keywords = triggers.slice(0, 12);
  153. const fmText = Object.entries(outFm)
  154. .map(([k, v]) => {
  155. if (Array.isArray(v)) return `${k}: [${v.join(', ')}]`;
  156. return `${k}: ${JSON.stringify(v)}`;
  157. })
  158. .join('\n');
  159. const output = `---\n${fmText}\n---\n${body}`;
  160. return { ok: issues.length === 0, issues, output, meta: { name, triggers } };
  161. }
  162. /** CLI 用法:node normalize-skill.mjs <skillDir> <skillName> */
  163. if (process.argv[1] && process.argv[1].endsWith('normalize-skill.mjs')) {
  164. const [dir, name] = process.argv.slice(2);
  165. if (!dir) {
  166. console.error('用法: node normalize-skill.mjs <skillDir> <skillName>');
  167. process.exit(1);
  168. }
  169. const r = normalizeSkill(dir, name || path.basename(dir));
  170. if (r.ok) {
  171. console.log(r.output);
  172. } else {
  173. console.error('规范化失败:', r.issues.join('; '));
  174. process.exit(1);
  175. }
  176. }