lib.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * skill-case-get 共享工具层
  14. *
  15. * 零依赖。集中处理:
  16. * - 仓库内路径解析(技能可能被拷到 ~/.claude/skills/,也可能在仓库内直接跑)
  17. * - 标签字典读写(自进化)
  18. * - 幂等键生成(tenantId + idempotencyKey)
  19. * - 素材/案例字段规范化(与 SHARED-CONTRACT.md 逐字对齐)
  20. * - 绝对不做的事:不写任何真实 PII、不落 token、不碰 .env
  21. *
  22. * ⚠️ 本仓库禁止出现任何密钥/token/私网地址。token 只从用户环境读取,仅内存持有。
  23. */
  24. import fs from 'node:fs';
  25. import path from 'node:path';
  26. import os from 'node:os';
  27. import crypto from 'node:crypto';
  28. import { fileURLToPath } from 'node:url';
  29. export const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
  30. export const SKILL_ROOT = path.resolve(SCRIPT_DIR, '..');
  31. export const TENANT_DEFAULT = process.env.CASE_TENANT_ID || 'lumi-demo';
  32. // ---------------------------------------------------------------------------
  33. // 文件与 JSON
  34. // ---------------------------------------------------------------------------
  35. export function readJson(filePath, fallback = null) {
  36. try {
  37. if (!filePath || !fs.existsSync(filePath)) return fallback;
  38. // 手写的 json 可能带 UTF-8 BOM
  39. const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, '');
  40. return JSON.parse(raw);
  41. } catch {
  42. return fallback;
  43. }
  44. }
  45. export function writeJson(filePath, value) {
  46. fs.mkdirSync(path.dirname(filePath), { recursive: true });
  47. fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
  48. }
  49. export function ensureDir(dir) {
  50. fs.mkdirSync(dir, { recursive: true });
  51. return dir;
  52. }
  53. export function sha1(value) {
  54. return crypto.createHash('sha1').update(String(value ?? '')).digest('hex');
  55. }
  56. export function shortHash(value, length = 10) {
  57. return sha1(value).slice(0, length);
  58. }
  59. // ---------------------------------------------------------------------------
  60. // 兄弟技能脚本解析(skill-vision / skill-listen)
  61. // ---------------------------------------------------------------------------
  62. function candidateRoots(envVar) {
  63. const roots = [];
  64. // 注意:envVar 的显式覆盖在 resolveSiblingScript 里优先处理,这里不再重复入列。
  65. roots.push(path.join(os.homedir(), '.claude', 'skills'));
  66. roots.push(path.join(process.cwd(), '.claude', 'skills'));
  67. roots.push(path.join(SKILL_ROOT, '..'));
  68. // 平台级技能目录(本部署的兄弟技能 fmode-storage 落在这里,不在 ~/.claude/skills):
  69. // 1) CASE_SKILLS_DIR 显式指定;
  70. // 2) $HERMES_HOME/skills(容器默认 HERMES_HOME=/opt/data);
  71. // 3) /opt/data/skills 兜底。
  72. // 命中与否由 resolveSiblingScript 的 existsSync 判定,多列不存在的根目录无副作用。
  73. if (process.env.CASE_SKILLS_DIR) roots.push(process.env.CASE_SKILLS_DIR);
  74. if (process.env.HERMES_HOME) roots.push(path.join(process.env.HERMES_HOME, 'skills'));
  75. roots.push('/opt/data/skills');
  76. return roots;
  77. }
  78. /**
  79. * 定位兄弟技能里的脚本。找不到返回 null(调用方给可执行的降级提示,不抛栈)。
  80. *
  81. * 环境变量(如 CASE_STORAGE_SCRIPT / CASE_VISION_SCRIPT)一旦显式设置,就是**权威覆盖**:
  82. * 先按「skills 根目录」解释(env/相对路径),再按「脚本文件本身」解释;
  83. * 两种都不存在时直接返回 null,**不再回落到默认搜索路径**——否则调用方想模拟
  84. * 「兄弟技能缺失」时会意外命中真实技能(测试与非预期环境都会踩)。
  85. *
  86. * @param {string} envVar 允许用环境变量覆盖的变量名
  87. * @param {string} relativePath 相对 skills 根目录的路径,如 skill-vision/scripts/vision-client.mjs
  88. */
  89. export function resolveSiblingScript(envVar, relativePath) {
  90. const override = envVar && process.env[envVar];
  91. if (override) {
  92. const asRoot = path.join(override, relativePath);
  93. if (fs.existsSync(asRoot)) return asRoot;
  94. if (fs.existsSync(override) && fs.statSync(override).isFile()) return override;
  95. return null;
  96. }
  97. for (const root of candidateRoots(envVar)) {
  98. const full = path.join(root, relativePath);
  99. if (fs.existsSync(full)) return full;
  100. }
  101. return null;
  102. }
  103. export function resolvePython() {
  104. return process.env.CASE_PYTHON || 'python3';
  105. }
  106. // ---------------------------------------------------------------------------
  107. // 标签字典
  108. // ---------------------------------------------------------------------------
  109. export function dictionaryPath(custom) {
  110. if (custom) return path.resolve(custom);
  111. return path.join(SKILL_ROOT, 'references', 'tag-dictionary.json');
  112. }
  113. export function loadDictionary(custom) {
  114. const file = dictionaryPath(custom);
  115. const dict = readJson(file, null);
  116. if (!dict || !dict.dimensions) {
  117. throw new Error(`标签字典不可用或结构不对:${file}`);
  118. }
  119. if (!Array.isArray(dict.__learned)) dict.__learned = [];
  120. dict.__file = file;
  121. return dict;
  122. }
  123. /** 取某维度的候选取值(数组;schoolAlias 这类结构特殊,单独处理)。 */
  124. export function dimensionValues(dict, dimension) {
  125. const dim = dict && dict.dimensions ? dict.dimensions[dimension] : null;
  126. if (!dim) return [];
  127. if (Array.isArray(dim.values)) return dim.values;
  128. return [];
  129. }
  130. /**
  131. * 字典自进化:把采集过程中遇到的新取值追加进字典。
  132. * 只追加、不改写既有条目;带 source/learnedAt 便于追溯。
  133. *
  134. * @returns {{added: Array<{dimension:string, value:string}>, skipped: Array<{dimension:string,value:string,reason:string}>}}
  135. */
  136. export function learnTags(dict, incoming) {
  137. const added = [];
  138. const skipped = [];
  139. const now = new Date().toISOString();
  140. for (const item of incoming || []) {
  141. const dimension = String(item && item.dimension || '').trim();
  142. const value = String(item && item.value || '').trim();
  143. if (!dimension || !value) {
  144. skipped.push({ dimension, value, reason: 'empty' });
  145. continue;
  146. }
  147. const dim = dict.dimensions ? dict.dimensions[dimension] : null;
  148. if (!dim) {
  149. skipped.push({ dimension, value, reason: 'unknown-dimension' });
  150. continue;
  151. }
  152. if (!Array.isArray(dim.values)) {
  153. // 结构型维度(schoolAlias)走 addAlias
  154. skipped.push({ dimension, value, reason: 'structured-dimension' });
  155. continue;
  156. }
  157. if (dim.values.includes(value)) {
  158. skipped.push({ dimension, value, reason: 'exists' });
  159. continue;
  160. }
  161. dim.values.push(value);
  162. added.push({ dimension, value });
  163. }
  164. if (added.length) {
  165. dict.updatedAt = now;
  166. dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : [];
  167. for (const entry of added) {
  168. dict.__learned.push({ ...entry, source: 'learned', learnedAt: now });
  169. }
  170. }
  171. return { added, skipped };
  172. }
  173. /** 学校别名自进化:追加到 schoolAlias.entries。 */
  174. export function learnAliases(dict, entries) {
  175. const added = [];
  176. const dim = dict.dimensions && dict.dimensions.schoolAlias;
  177. if (!dim || !Array.isArray(dim.entries)) return { added };
  178. const existing = new Set(dim.entries.map((e) => String(e.aliasText || '').toLowerCase()));
  179. const now = new Date().toISOString();
  180. for (const item of entries || []) {
  181. const aliasText = String(item && item.aliasText || '').trim();
  182. const canonicalName = String(item && item.canonicalName || '').trim();
  183. if (!aliasText || !canonicalName) continue;
  184. if (existing.has(aliasText.toLowerCase())) continue;
  185. const entry = { aliasText, canonicalName, country: item.country || '', source: 'learned', learnedAt: now };
  186. dim.entries.push(entry);
  187. existing.add(aliasText.toLowerCase());
  188. added.push(entry);
  189. }
  190. if (added.length) {
  191. dict.updatedAt = now;
  192. dict.__learned = Array.isArray(dict.__learned) ? dict.__learned : [];
  193. for (const entry of added) dict.__learned.push({ dimension: 'schoolAlias', value: entry.aliasText, source: 'learned', learnedAt: now });
  194. }
  195. return { added };
  196. }
  197. export function saveDictionary(dict) {
  198. const { __file, __learned, ...clean } = dict;
  199. writeJson(dict.__file || dictionaryPath(), clean);
  200. return dict.__file || dictionaryPath();
  201. }
  202. // ---------------------------------------------------------------------------
  203. // 幂等
  204. // ---------------------------------------------------------------------------
  205. /**
  206. * 生成幂等键。同一批素材重复采集(同 sourceRef + 同标题 + 同素材序列)必须得到同一个键,
  207. * 保证 `tenantId + idempotencyKey` 去重、重复提交只产生一条。
  208. */
  209. export function buildIdempotencyKey({ tenantId, sourceType, sourceRef, title, materialAssets, explicit }) {
  210. if (explicit) return String(explicit);
  211. const assetFingerprint = (materialAssets || [])
  212. .map((a) => `${a.order || ''}:${a.kind || ''}:${a.url || a.localPath || ''}`)
  213. .join('|');
  214. const seed = [tenantId || TENANT_DEFAULT, sourceType || '', sourceRef || '', title || '', assetFingerprint].join('::');
  215. return `caseget-${shortHash(seed, 16)}`;
  216. }
  217. /**
  218. * 为本地素材推导可访问 URL。
  219. *
  220. * 技能本机不存储/上传二进制(不新建业务服务器),因此:
  221. * - 调用了 `--asset-base-url <base>`(已有 CDN / Parse Files 前缀)→ 用 base + 文件名派生 URL;
  222. * - 没提供 → 返回空串,并在调用处给出警告,由归档方补齐 URL 后再入库。
  223. * 绝不伪造 URL,也不把本地绝对路径当 URL 写进案例包。
  224. */
  225. export function deriveAssetUrl(localPath, baseUrl) {
  226. if (!baseUrl) return '';
  227. const name = String(localPath || '').split(/[\\/]/).pop() || '';
  228. if (!name) return '';
  229. // 路径片段保留、文件名做 URL 安全化,避免空格 / 中文导致的坏链接
  230. const safe = encodeURIComponent(name).replace(/%2F/g, '_');
  231. return `${String(baseUrl).replace(/\/+$/, '')}/${safe}`;
  232. }
  233. // ---------------------------------------------------------------------------
  234. // 素材/案例字段规范化(逐字对齐 SHARED-CONTRACT.md)
  235. // ---------------------------------------------------------------------------
  236. export const MATERIAL_KINDS = new Set(['image', 'video']);
  237. export const MATERIAL_ROLES = new Set(['material', 'description']);
  238. /**
  239. * 规范化单个素材项。order 1 起、同组内按九宫格阅读顺序。
  240. * @returns {{asset: object|null, problems: string[]}}
  241. */
  242. export function normalizeMaterialAsset(raw, index) {
  243. const problems = [];
  244. const item = raw || {};
  245. const kind = String(item.kind || 'image').toLowerCase();
  246. if (!MATERIAL_KINDS.has(kind)) problems.push(`素材 #${index + 1} 的 kind 非法:${item.kind}`);
  247. const order = Number.isFinite(Number(item.order)) && Number(item.order) > 0
  248. ? Math.trunc(Number(item.order))
  249. : index + 1;
  250. const url = item.url || item.imageUrl || item.videoUrl || '';
  251. const localPath = item.localPath || item.path || '';
  252. if (!url && !localPath) problems.push(`素材 #${index + 1} 既没有 url 也没有 localPath`);
  253. const role = String(item.role || 'material').toLowerCase();
  254. if (!MATERIAL_ROLES.has(role)) problems.push(`素材 #${index + 1} 的 role 非法:${item.role}`);
  255. const asset = {
  256. order,
  257. groupId: item.groupId || '',
  258. kind,
  259. url: String(url || ''),
  260. localPath: String(localPath || ''),
  261. label: String(item.label || ''),
  262. ocrText: String(item.ocrText || ''),
  263. description: String(item.description || ''),
  264. usageSuggestion: String(item.usageSuggestion || ''),
  265. role,
  266. };
  267. return { asset, problems };
  268. }
  269. /** 按 order 升序排序(稳定;同 order 保持原相对顺序)。 */
  270. export function sortAssets(assets) {
  271. return (assets || [])
  272. .map((asset, index) => ({ asset, index }))
  273. .sort((a, b) => (a.asset.order - b.asset.order) || (a.index - b.index))
  274. .map((entry) => entry.asset);
  275. }
  276. /**
  277. * 手动调整顺序:把指定素材移动到目标位置,其余顺次重排(order 从 1 连续)。
  278. * 支持「数组 + 顺序调整」的显式要求。
  279. */
  280. export function reorderAssets(assets, from, to) {
  281. const list = sortAssets(assets);
  282. const start = Math.trunc(from) - 1;
  283. const end = Math.trunc(to) - 1;
  284. if (start < 0 || start >= list.length || end < 0 || end >= list.length) {
  285. throw new Error(`reorder 越界:from=${from} to=${to},共 ${list.length} 项`);
  286. }
  287. const [moved] = list.splice(start, 1);
  288. list.splice(end, 0, moved);
  289. return list.map((asset, index) => ({ ...asset, order: index + 1 }));
  290. }
  291. /** 汇总兼容字段:imageUrls = kind='image' 的 url,按 order 排序。 */
  292. export function imageUrlsFrom(assets) {
  293. return sortAssets(assets)
  294. .filter((a) => a.kind === 'image' && a.url)
  295. .map((a) => a.url);
  296. }
  297. // ---------------------------------------------------------------------------
  298. // 输出目录
  299. // ---------------------------------------------------------------------------
  300. export function outputsDir(custom) {
  301. return path.resolve(custom || process.env.CASE_OUTPUT_DIR || path.join(SKILL_ROOT, 'outputs'));
  302. }
  303. export function newRunId() {
  304. const now = new Date();
  305. const stamp = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
  306. return `${stamp}-${shortHash(`${now.getTime()}-${Math.random()}`, 6)}`;
  307. }
  308. // ---------------------------------------------------------------------------
  309. // 通用小工具
  310. // ---------------------------------------------------------------------------
  311. /**
  312. * 去重并丢弃空值。同时接受多个数组(或嵌套数组):
  313. * uniq(['a']), uniq(['a'], ['b']) 都成立,避免调用处到处 spread。
  314. */
  315. export function uniq(...args) {
  316. return [...new Set(
  317. args
  318. .flat(Infinity)
  319. .filter((item) => item !== undefined && item !== null && item !== ''),
  320. )];
  321. }
  322. export function firstText(...values) {
  323. for (const value of values) {
  324. if (typeof value === 'string' && value.trim()) return value.trim();
  325. }
  326. return '';
  327. }
  328. export function truncate(value, max = 160) {
  329. const text = String(value ?? '').replace(/\s+/g, ' ').trim();
  330. return text.length > max ? `${text.slice(0, max)}…` : text;
  331. }