@yottameta/yotta-skills-plugin 0.19.16 → 0.19.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const scanLib = require('./skills-scan');
7
+
8
+ function nowTag() {
9
+ return new Date().toISOString().replace(/[:.]/g, '-');
10
+ }
11
+
12
+ function snapshotRoot(homeDir, slug) {
13
+ return path.join(homeDir || os.homedir(), '.yottaskills', 'snapshots', slug);
14
+ }
15
+
16
+ function copyTree(src, dst) {
17
+ fs.rmSync(dst, { recursive: true, force: true });
18
+ fs.mkdirSync(dst, { recursive: true });
19
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
20
+ const from = path.join(src, entry.name);
21
+ const to = path.join(dst, entry.name);
22
+ if (entry.isDirectory()) copyTree(from, to);
23
+ else if (entry.isFile()) fs.copyFileSync(from, to);
24
+ }
25
+ }
26
+
27
+ function renameWithRetry(from, to, options) {
28
+ const rename = (options && options.rename) || fs.renameSync;
29
+ let lastError;
30
+ for (let attempt = 0; attempt < 5; attempt++) {
31
+ try {
32
+ return rename(from, to);
33
+ } catch (error) {
34
+ lastError = error;
35
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 4) throw error;
36
+ }
37
+ }
38
+ throw lastError;
39
+ }
40
+
41
+ function walkFiles(root, current, output) {
42
+ const dir = current || root;
43
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) walkFiles(root, full, output);
46
+ else if (entry.isFile()) output.push(full);
47
+ }
48
+ }
49
+
50
+ function computeTreeDigest(root) {
51
+ const files = [];
52
+ walkFiles(path.resolve(root), null, files);
53
+ files.sort((a, b) => path.relative(root, a).localeCompare(path.relative(root, b)));
54
+ const hash = crypto.createHash('sha256');
55
+ for (const file of files) {
56
+ const relative = path.relative(root, file).replace(/\\/g, '/');
57
+ hash.update(relative, 'utf8');
58
+ hash.update('\0', 'utf8');
59
+ hash.update(fs.readFileSync(file));
60
+ hash.update('\0', 'utf8');
61
+ }
62
+ return { digest: hash.digest('hex'), files };
63
+ }
64
+
65
+ function writeJsonAtomic(file, value) {
66
+ const tmp = file + '.tmp-' + process.pid;
67
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
68
+ fs.renameSync(tmp, file);
69
+ }
70
+
71
+ function readVersion(root) {
72
+ try {
73
+ const fm = scanLib.parseFrontmatter(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8'));
74
+ return fm && fm.version ? String(fm.version).trim() : null;
75
+ } catch (_) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function validateSnapshot(snapshot) {
81
+ const root = path.resolve(snapshot || '');
82
+ if (!snapshot || !fs.existsSync(root)) {
83
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照目录不存在' };
84
+ }
85
+ if (!fs.statSync(root).isDirectory()) {
86
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照不是目录' };
87
+ }
88
+ const metaFile = root + '.meta.json';
89
+ if (fs.existsSync(metaFile)) {
90
+ let metadata;
91
+ try {
92
+ metadata = JSON.parse(fs.readFileSync(metaFile, 'utf8'));
93
+ } catch (error) {
94
+ return { ok: false, version: null, digest: null, legacy: false, reason: '快照元数据无法解析: ' + error.message };
95
+ }
96
+ let current;
97
+ try {
98
+ current = computeTreeDigest(root);
99
+ } catch (error) {
100
+ return { ok: false, version: metadata.version || null, digest: metadata.digest || null, legacy: false, reason: '快照不可读: ' + error.message };
101
+ }
102
+ if (!metadata.digest || current.digest !== metadata.digest) {
103
+ return {
104
+ ok: false,
105
+ version: metadata.version || readVersion(root),
106
+ digest: metadata.digest || null,
107
+ legacy: false,
108
+ reason: '快照摘要不一致,可能已被修改',
109
+ };
110
+ }
111
+ return {
112
+ ok: true,
113
+ version: metadata.version || readVersion(root),
114
+ digest: metadata.digest,
115
+ legacy: false,
116
+ reason: null,
117
+ };
118
+ }
119
+
120
+ const version = readVersion(root);
121
+ if (!fs.existsSync(path.join(root, 'SKILL.md'))) {
122
+ return { ok: false, version, digest: null, legacy: true, reason: '旧快照缺少 SKILL.md' };
123
+ }
124
+ return { ok: true, version, digest: null, legacy: true, reason: null };
125
+ }
126
+
127
+ function createSnapshot(target, options) {
128
+ const opts = options || {};
129
+ if (!target || !fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
130
+ throw new Error('无法创建快照:目标目录不存在');
131
+ }
132
+ const slug = opts.slug || path.basename(target);
133
+ const root = snapshotRoot(opts.homeDir, slug);
134
+ fs.mkdirSync(root, { recursive: true });
135
+ const version = opts.version || readVersion(target) || 'unknown';
136
+ const snapshot = fs.mkdtempSync(path.join(root, nowTag() + '-' + version + '-'));
137
+ const copyDir = opts.copyDir || copyTree;
138
+ try {
139
+ copyDir(target, snapshot);
140
+ const integrity = computeTreeDigest(snapshot);
141
+ const metadata = {
142
+ schema: 1,
143
+ slug,
144
+ version,
145
+ created_at: new Date().toISOString(),
146
+ source: path.resolve(target),
147
+ digest: integrity.digest,
148
+ files: integrity.files.length,
149
+ };
150
+ writeJsonAtomic(snapshot + '.meta.json', metadata);
151
+ return {
152
+ path: snapshot,
153
+ version,
154
+ digest: integrity.digest,
155
+ files: integrity.files.length,
156
+ metadata,
157
+ };
158
+ } catch (error) {
159
+ fs.rmSync(snapshot, { recursive: true, force: true });
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ function listSnapshots(homeDir, slug) {
165
+ const roots = [];
166
+ if (slug) {
167
+ roots.push({ slug, dir: snapshotRoot(homeDir, slug) });
168
+ } else {
169
+ const base = path.join(homeDir || os.homedir(), '.yottaskills', 'snapshots');
170
+ if (fs.existsSync(base)) {
171
+ for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
172
+ if (entry.isDirectory()) roots.push({ slug: entry.name, dir: path.join(base, entry.name) });
173
+ }
174
+ }
175
+ }
176
+
177
+ const rows = [];
178
+ for (const root of roots) {
179
+ if (!fs.existsSync(root.dir)) continue;
180
+ for (const entry of fs.readdirSync(root.dir, { withFileTypes: true })) {
181
+ if (!entry.isDirectory()) continue;
182
+ const snapshot = path.join(root.dir, entry.name);
183
+ const validation = validateSnapshot(snapshot);
184
+ let mtimeMs = 0;
185
+ try { mtimeMs = fs.statSync(snapshot).mtimeMs; } catch (_) { /* keep 0 */ }
186
+ rows.push({
187
+ path: snapshot,
188
+ name: entry.name,
189
+ slug: root.slug,
190
+ valid: validation.ok,
191
+ version: validation.version,
192
+ legacy: validation.legacy,
193
+ reason: validation.reason,
194
+ mtime_ms: mtimeMs,
195
+ });
196
+ }
197
+ }
198
+ rows.sort((a, b) => (b.mtime_ms - a.mtime_ms) || b.name.localeCompare(a.name));
199
+ return rows;
200
+ }
201
+
202
+ function restoreSnapshot(snapshot, target, options) {
203
+ const validation = validateSnapshot(snapshot);
204
+ if (!validation.ok) {
205
+ return { ok: false, version: validation.version, error: validation.reason || '快照校验失败' };
206
+ }
207
+ const opts = options || {};
208
+ const destination = path.dirname(path.resolve(target));
209
+ const base = path.basename(path.resolve(target));
210
+ const copyDir = opts.copyDir || copyTree;
211
+ const staging = path.join(destination, '.yottaskills-rollback-' + base + '-' + process.pid + '-' + Date.now());
212
+ const backup = path.join(destination, '.yottaskills-rollback-backup-' + base + '-' + process.pid + '-' + Date.now());
213
+ let movedCurrent = false;
214
+ try {
215
+ fs.mkdirSync(destination, { recursive: true });
216
+ copyDir(snapshot, staging);
217
+ if (fs.existsSync(target)) {
218
+ renameWithRetry(target, backup, opts);
219
+ movedCurrent = true;
220
+ }
221
+ renameWithRetry(staging, target, opts);
222
+ if (movedCurrent && fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true });
223
+ return { ok: true, version: validation.version, error: null, snapshot };
224
+ } catch (error) {
225
+ try {
226
+ if (fs.existsSync(target) && movedCurrent) fs.rmSync(target, { recursive: true, force: true });
227
+ if (movedCurrent && fs.existsSync(backup)) renameWithRetry(backup, target, opts);
228
+ } catch (_) {
229
+ // Preserve both the failure and any recovered tree for manual inspection.
230
+ }
231
+ if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
232
+ return { ok: false, version: validation.version, error: error.message, snapshot };
233
+ }
234
+ }
235
+
236
+ module.exports = {
237
+ snapshotRoot,
238
+ copyTree,
239
+ computeTreeDigest,
240
+ createSnapshot,
241
+ validateSnapshot,
242
+ listSnapshots,
243
+ restoreSnapshot,
244
+ renameWithRetry,
245
+ };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const MANIFEST_FILE = 'skill-manifest.json';
6
+
7
+ function defaultManifest(skill) {
8
+ return {
9
+ manifestVersion: 1,
10
+ slug: skill.slug,
11
+ name: skill.name,
12
+ package: skill.pkg,
13
+ version: skill.version,
14
+ trust: 'yottameta',
15
+ install: {
16
+ idempotent: true,
17
+ network: 'registry',
18
+ },
19
+ permissions: {
20
+ filesystem: 'user-skills-dir',
21
+ network: 'registry',
22
+ process: 'child-process',
23
+ note: '仅调用本包内安装与自检脚本;默认无自定义脚本。',
24
+ },
25
+ auto_apply: {
26
+ mode: 'route',
27
+ note: '元阁只给路由建议;安装与调用均由用户确认后执行,不自动调用。',
28
+ },
29
+ };
30
+ }
31
+
32
+ function readJson(file) {
33
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
34
+ }
35
+
36
+ function readSkillVersion(pkgDir) {
37
+ try {
38
+ const text = fs.readFileSync(path.join(pkgDir, 'SKILL.md'), 'utf8');
39
+ const match = text.match(/^version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
40
+ return match ? match[1] : null;
41
+ } catch (_) {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function isSafeRelativePath(value) {
47
+ if (typeof value !== 'string' || value.trim() === '') return false;
48
+ if (path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value)) return false;
49
+ const parts = value.split(/[\\/]+/);
50
+ return !parts.includes('..');
51
+ }
52
+
53
+ function validateManifest(manifest, context) {
54
+ const errors = [];
55
+ const skill = context.skill;
56
+ const pkgDir = context.pkgDir;
57
+ const required = ['manifestVersion', 'slug', 'name', 'package', 'version', 'trust', 'install', 'permissions'];
58
+ for (const key of required) {
59
+ if (manifest[key] === undefined) errors.push('缺少字段: ' + key);
60
+ }
61
+ if (manifest.manifestVersion !== 1) errors.push('manifestVersion 必须为 1');
62
+ if (manifest.slug !== skill.slug) errors.push('slug 与清单不一致');
63
+ if (manifest.package !== skill.pkg) errors.push('package 与清单不一致');
64
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(manifest.slug || ''))) errors.push('slug 格式非法');
65
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(String(manifest.version || ''))) {
66
+ errors.push('version 格式非法');
67
+ }
68
+ if (!['yottameta', 'third-party-verified', 'unknown'].includes(manifest.trust)) errors.push('trust 非法');
69
+ if (!manifest.install || manifest.install.idempotent !== true) errors.push('install.idempotent 必须为 true');
70
+ if (!manifest.permissions || typeof manifest.permissions !== 'object') errors.push('permissions 缺失');
71
+ if (manifest.permissions && !['none', 'user-skills-dir', 'project-dir', 'user-config', 'other'].includes(manifest.permissions.filesystem)) {
72
+ errors.push('permissions.filesystem 非法');
73
+ }
74
+ if (manifest.permissions && !['none', 'registry', 'allowlist', 'other'].includes(manifest.permissions.network)) {
75
+ errors.push('permissions.network 非法');
76
+ }
77
+
78
+ let packageMeta = null;
79
+ const packageJsonPath = path.join(pkgDir, 'package.json');
80
+ if (fs.existsSync(packageJsonPath)) {
81
+ try {
82
+ packageMeta = readJson(packageJsonPath);
83
+ } catch (error) {
84
+ errors.push('无法读取 package.json: ' + error.message);
85
+ }
86
+ } else if (context.requirePackageJson !== false) {
87
+ errors.push('无法读取 package.json: 文件不存在');
88
+ }
89
+ if (packageMeta && packageMeta.name !== skill.pkg) errors.push('package.json.name 与清单不一致');
90
+ if (packageMeta && packageMeta.version !== manifest.version) {
91
+ errors.push('package.json.version 与 manifest.version 不一致');
92
+ }
93
+ const skillVersion = readSkillVersion(pkgDir);
94
+ if (skillVersion !== manifest.version) errors.push('SKILL.md.version 与 manifest.version 不一致');
95
+ for (const key of ['setup', 'doctor', 'rollback']) {
96
+ const rel = manifest.install && manifest.install[key];
97
+ if (rel !== undefined && !isSafeRelativePath(rel)) errors.push('install.' + key + ' 路径不安全');
98
+ }
99
+ return { ok: errors.length === 0, errors };
100
+ }
101
+
102
+ function loadManifest(context) {
103
+ const file = path.join(context.pkgDir, MANIFEST_FILE);
104
+ if (!fs.existsSync(file)) {
105
+ return {
106
+ manifest: defaultManifest(context.skill),
107
+ source: 'family-default',
108
+ errors: [],
109
+ };
110
+ }
111
+
112
+ let value;
113
+ try {
114
+ value = readJson(file);
115
+ } catch (error) {
116
+ return {
117
+ manifest: null,
118
+ source: 'package',
119
+ errors: ['无法解析 skill-manifest.json: ' + error.message],
120
+ };
121
+ }
122
+ const validation = validateManifest(value, context);
123
+ return {
124
+ manifest: value,
125
+ source: 'package',
126
+ errors: validation.errors,
127
+ };
128
+ }
129
+
130
+ module.exports = {
131
+ MANIFEST_FILE,
132
+ defaultManifest,
133
+ loadManifest,
134
+ validateManifest,
135
+ isSafeRelativePath,
136
+ };
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+
3
+ const PLAYBOOKS = [
4
+ {
5
+ id: 'output-standard',
6
+ name: '输出呈现标准',
7
+ intent: '让输出先判型渲染为可复制格式,再去除 AI 味。',
8
+ keywords: ['输出', '呈现', '渲染', '排版', '可复制', 'AI味', 'AI 味', '润色', '文档', '报告'],
9
+ skills: [
10
+ { slug: 'yotta-present', order: 1, role: '判断内容形态并渲染为可复制 Markdown / 纯文本。' },
11
+ { slug: 'yotta-humanize', order: 2, role: '检测中文 AI 腔,并按用户原意做确定性改写。' },
12
+ ],
13
+ },
14
+ {
15
+ id: 'long-lived-agent',
16
+ name: '长生命周期智能体',
17
+ intent: '让智能体跨会话恢复状态、记忆、经验与历史。',
18
+ keywords: ['长期', '跨会话', '忘了我', '别忘了我', '记忆', '上下文', '状态', '历史'],
19
+ skills: [
20
+ { slug: 'yotta-workflow', order: 1, role: '开工读取项目状态,会话结束更新状态并留下次接续记录。' },
21
+ { slug: 'yotta-memory', order: 2, role: '按权限边界保存与恢复跨会话记忆。' },
22
+ { slug: 'yotta-learn', order: 3, role: '沉淀错误、纠正与洞见,供后续会话复用。' },
23
+ { slug: 'yotta-logs', order: 4, role: '按需检索历史会话与日志记录。' },
24
+ ],
25
+ },
26
+ {
27
+ id: 'delivery-quality-gate',
28
+ name: '交付质量门',
29
+ intent: '交付前做防敷衍、代码质量与发布守门检查。',
30
+ keywords: ['检查', '代码', '质量', '别糊弄', '敷衍', '评审', '发布', '守门', '交付'],
31
+ skills: [
32
+ { slug: 'yotta-anti-shallow', order: 1, role: '强制先分析、再执行、后自检,防止表面化交付。' },
33
+ { slug: 'yotta-code-quality', order: 2, role: '按十二类腐化风险做结对式代码质量评审。' },
34
+ { slug: 'yotta-publish-guard', order: 3, role: '发布前校验版本、发布件与分发通道一致性。' },
35
+ ],
36
+ },
37
+ {
38
+ id: 'preinstall-security-gate',
39
+ name: '装前安全门',
40
+ intent: '安装其他来源技能、插件或 MCP 前做安全判定。',
41
+ keywords: ['安装', '装前', '其他来源', '技能', '插件', 'MCP', '安全扫描'],
42
+ skills: [
43
+ { slug: 'yotta-verify', order: 1, role: '执行确定性装前扫描并给出安全判定。' },
44
+ { slug: 'yotta-vetter', order: 2, role: '按四阶段协议审查来源、代码、权限与风险。' },
45
+ { slug: 'yotta-security-audit', order: 3, role: '做深度静态检测与安全基线兜底。' },
46
+ ],
47
+ },
48
+ {
49
+ id: 'skill-build-publish',
50
+ name: '造技能 / 发版',
51
+ intent: '创建合规技能包,并在发布前完成守门检查。',
52
+ keywords: ['造一个', '新 skill', '新技能', '脚手架', '发版', '发布技能', '技能包'],
53
+ skills: [
54
+ { slug: 'yotta-skill-creator', order: 1, role: '生成符合规范的技能目录与发布件结构。' },
55
+ { slug: 'yotta-publish-guard', order: 2, role: '校验版本四件、发布件与分发通道。' },
56
+ ],
57
+ },
58
+ {
59
+ id: 'security-incident-response',
60
+ name: '安全事件响应',
61
+ intent: '从日志、情报、样本、密钥、供应链与资产侦察看清事件链路。',
62
+ keywords: ['日志', '攻击', '安全事件', '入侵', 'IOC', '威胁情报', '样本', '密钥泄露', '供应链', '资产', '侦察'],
63
+ skills: [
64
+ { slug: 'yotta-logwatch', order: 1, role: '分析安全日志并识别攻击链与告警聚合。' },
65
+ { slug: 'yotta-intel', order: 2, role: '提取、去武器化并规范化威胁情报 IOC。' },
66
+ { slug: 'yotta-triage', order: 3, role: '对可疑样本做静态初筛。' },
67
+ { slug: 'yotta-secret', order: 4, role: '扫描密钥与凭据泄露源头。' },
68
+ { slug: 'yotta-chain', order: 5, role: '校验依赖供应链与 lockfile 一致性。' },
69
+ { slug: 'yotta-recon', order: 6, role: '在授权范围内做端口、服务与版本指纹侦察。' },
70
+ ],
71
+ },
72
+ {
73
+ id: 'entry-install',
74
+ name: '入口 + 安装',
75
+ intent: '需求方向不明确时先澄清意图,再按需安装合适技能。',
76
+ keywords: ['不知道用哪个', '需求模糊', '该用哪个', '怎么说', '技能该用哪个'],
77
+ skills: [
78
+ { slug: 'yotta-prompt', order: 1, role: '澄清需求意图并串联到合适的技能方向。' },
79
+ { slug: 'yotta-skills', order: 2, role: '按场景组合安装所需技能,不默认安装全家。' },
80
+ ],
81
+ },
82
+ ];
83
+
84
+ const OTHER_SKILLS_NOTE = '其他已装技能仅作并列候选提示:机械匹配 frontmatter description,未做语义推理、未读取全文指令、未自动调用;数据不出本机。';
85
+ const OTHER_SKILL_NOTE = '非元阁家族技能:仅按 frontmatter description 本地机械匹配;不读取全文指令、不自动调用;使用/安装前请先做装前安全扫描。';
86
+ const EN_STOPWORDS = new Set([
87
+ 'a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'from',
88
+ 'this', 'that', 'is', 'are', 'was', 'were', 'be', 'been', 'your', 'you', 'our',
89
+ 'we', 'it', 'at', 'by', 'as', 'not', 'no', 'so', 'do', 'does',
90
+ ]);
91
+
92
+ /** 元阁家族 slug 集合:playbook 引用 + 已知不入 playbook 的元技能(如 yotta-chart)。 */
93
+ function defaultYottaSlugs() {
94
+ const set = new Set(['yotta-chart']);
95
+ for (const playbook of PLAYBOOKS) {
96
+ for (const skill of playbook.skills) set.add(skill.slug);
97
+ }
98
+ return Array.from(set);
99
+ }
100
+
101
+ /** 英文词项:≥3 字母的单词,过滤停用词(保留出现顺序、去重)。 */
102
+ function enTokens(text) {
103
+ const tokens = [];
104
+ const seen = new Set();
105
+ for (const m of String(text || '').toLowerCase().match(/[a-z][a-z0-9]{2,}/g) || []) {
106
+ if (EN_STOPWORDS.has(m) || seen.has(m)) continue;
107
+ seen.add(m);
108
+ tokens.push(m);
109
+ }
110
+ return tokens;
111
+ }
112
+
113
+ /** 中文词项:CJK 连续片段内相邻两字 bigram(零依赖确定性分词)。 */
114
+ function cjkBigrams(text) {
115
+ const bigrams = [];
116
+ const seen = new Set();
117
+ for (const m of String(text || '').match(/[\u4e00-\u9fff]+/g) || []) {
118
+ for (let i = 0; i < m.length - 1; i++) {
119
+ const g = m.slice(i, i + 2);
120
+ if (seen.has(g)) continue;
121
+ seen.add(g);
122
+ bigrams.push(g);
123
+ }
124
+ }
125
+ return bigrams;
126
+ }
127
+
128
+ /** 需求与候选文本的词项匹配:英文 token + 中文 bigram 交集,按需求侧出现顺序输出。 */
129
+ function scoreOtherSkill(request, record) {
130
+ const candidateText = [record.description, record.slug].filter(Boolean).join(' ');
131
+ const reqTokens = enTokens(request);
132
+ const reqBigrams = cjkBigrams(request);
133
+ const candTokens = new Set(enTokens(candidateText));
134
+ const candBigrams = new Set(cjkBigrams(candidateText));
135
+ const matched = [];
136
+ for (const item of reqTokens) if (candTokens.has(item)) matched.push(item);
137
+ for (const item of reqBigrams) if (candBigrams.has(item)) matched.push(item);
138
+ return { score: matched.length, matched_terms: matched };
139
+ }
140
+
141
+ function normalizeText(text) {
142
+ return String(text || '').toLowerCase().replace(/\s+/g, '');
143
+ }
144
+
145
+ function scorePlaybook(request, playbook) {
146
+ const text = normalizeText(request);
147
+ const matchedKeywords = [];
148
+ let score = 0;
149
+ for (const keyword of playbook.keywords) {
150
+ if (text.includes(normalizeText(keyword))) {
151
+ matchedKeywords.push(keyword);
152
+ score += normalizeText(keyword).length >= 3 ? 2 : 1;
153
+ }
154
+ }
155
+ return { score, matchedKeywords };
156
+ }
157
+
158
+ function routeRequest(request, options) {
159
+ const registry = (options && options.registry) || { skills: {} };
160
+ const installed = registry.skills || {};
161
+ const yottaSlugs = new Set((options && options.yottaSlugs) || defaultYottaSlugs());
162
+ const otherSkillsTopN = (options && options.otherSkillsTopN) || 3;
163
+ const ranked = PLAYBOOKS
164
+ .map((playbook) => ({ playbook, ...scorePlaybook(request, playbook) }))
165
+ .sort((a, b) => b.score - a.score || a.playbook.id.localeCompare(b.playbook.id));
166
+ const best = ranked[0];
167
+ const runnerUp = ranked[1];
168
+ const fallback = !best || best.score === 0;
169
+ const playbook = fallback
170
+ ? PLAYBOOKS.find((item) => item.id === 'entry-install')
171
+ : best.playbook;
172
+ const matchedKeywords = fallback ? [] : best.matchedKeywords;
173
+ const margin = fallback ? 0 : best.score - (runnerUp ? runnerUp.score : 0);
174
+ const confidence = !fallback && best.score >= 3 && margin >= 1
175
+ ? 'high'
176
+ : (!fallback && best.score >= 1 ? 'medium' : 'low');
177
+
178
+ const skills = playbook.skills.map((skill) => {
179
+ const record = installed[skill.slug];
180
+ const isInstalled = Boolean(record && record.status !== 'gone');
181
+ return {
182
+ ...skill,
183
+ installed: isInstalled,
184
+ version: isInstalled ? record.version || '' : '',
185
+ sources: isInstalled ? record.sources || [] : [],
186
+ variants: isInstalled && Array.isArray(record.variants) ? record.variants : [],
187
+ conflicts: isInstalled && Array.isArray(record.conflicts) ? record.conflicts : [],
188
+ };
189
+ });
190
+ const missingSkills = skills
191
+ .filter((skill) => !skill.installed)
192
+ .map((skill) => ({ slug: skill.slug, scan_required: true }));
193
+ const installCommand = missingSkills.length
194
+ ? 'npx -y @yottameta/yotta-skills install '
195
+ + missingSkills.map((skill) => skill.slug).join(' ')
196
+ + ' --dir <skills-dir>'
197
+ : null;
198
+
199
+ const otherCandidates = Object.values(installed)
200
+ .filter((record) => record && record.status !== 'gone' && !yottaSlugs.has(record.slug))
201
+ .map((record) => ({ record, ...scoreOtherSkill(request, record) }))
202
+ .filter((item) => item.score > 0)
203
+ .sort((a, b) => b.score - a.score || a.record.slug.localeCompare(b.record.slug))
204
+ .slice(0, otherSkillsTopN)
205
+ .map(({ record, score, matched_terms }) => ({
206
+ slug: record.slug,
207
+ version: record.version || '',
208
+ sources: record.sources || [],
209
+ description: record.description || '',
210
+ score,
211
+ matched_terms,
212
+ scan_status: 'not_scanned',
213
+ note: OTHER_SKILL_NOTE,
214
+ }));
215
+
216
+ return {
217
+ request,
218
+ playbook: {
219
+ id: playbook.id,
220
+ name: playbook.name,
221
+ intent: playbook.intent,
222
+ },
223
+ confidence,
224
+ matched_keywords: matchedKeywords,
225
+ skills,
226
+ missing_skills: missingSkills,
227
+ install_command: installCommand,
228
+ auto_install: false,
229
+ application_mode: {
230
+ default: 'explicit',
231
+ options: ['explicit', 'scene-auto'],
232
+ note: '默认用户显式调用;切换为按场景自动调用需要用户确认。',
233
+ },
234
+ disclaimer: '路由建议不保证完全正确;关键动作应由用户确认,数据不出本机。',
235
+ other_skill_candidates: otherCandidates,
236
+ other_skills_note: OTHER_SKILLS_NOTE,
237
+ };
238
+ }
239
+
240
+ module.exports = { PLAYBOOKS, defaultYottaSlugs, routeRequest };