@bolloon/bolloon-agent 0.4.24 → 0.4.26
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.
- package/dist/agents/execution-supervisor.js +446 -0
- package/dist/agents/external-events.js +162 -0
- package/dist/agents/goal-criteria.js +124 -0
- package/dist/agents/goal-store.js +526 -0
- package/dist/agents/pi-harness.js +263 -0
- package/dist/agents/pi-sdk.js +607 -126
- package/dist/agents/run-store.js +772 -0
- package/dist/agents/runner-resolver.js +225 -0
- package/dist/agents/skill-readiness.js +133 -0
- package/dist/agents/skill-supervisor-link.js +70 -0
- package/dist/agents/skills-manager.js +717 -0
- package/dist/agents/supervisor-host.js +249 -0
- package/dist/cli/setup-wizard.js +96 -127
- package/dist/cron/tick-lock.js +1 -1
- package/dist/electron/first-run.js +33 -2
- package/dist/electron-build/electron/first-run.js +35 -2
- package/dist/electron-build/electron/first-run.js.map +1 -1
- package/dist/index.js +549 -26
- package/dist/ios/agent-delegate-server.js +58 -12
- package/dist/ios/icons/icon-1024x1024.png +0 -0
- package/dist/ios/icons/icon-1024x1024.webp +0 -0
- package/dist/ios/icons/icon-216x216.png +0 -0
- package/dist/ios/icons/icon-216x216.webp +0 -0
- package/dist/ios/index.html +21 -1
- package/dist/ios/manifest.json +1 -1
- package/dist/ios/mobile-agent.js +195 -1
- package/dist/ios/mobile-core.js +24876 -24723
- package/dist/ios/mobile.css +15 -0
- package/dist/ios/mobile.html +21 -1
- package/dist/ios/mobile.js +143 -0
- package/dist/ios/server.js +51 -4
- package/dist/llm/config-store.js +35 -4
- package/dist/network/agent-network.js +10 -0
- package/dist/network/goal-event-bridge.js +57 -0
- package/dist/setup/onboard.js +549 -0
- package/dist/setup/setup-store.js +592 -0
- package/dist/web/icons/icon-1024x1024.png +0 -0
- package/dist/web/icons/icon-1024x1024.webp +0 -0
- package/dist/web/icons/icon-216x216.png +0 -0
- package/dist/web/icons/icon-216x216.webp +0 -0
- package/dist/web/manifest.json +1 -1
- package/dist/web/mobile-agent.js +2 -2
- package/dist/web/mobile-core.js +24884 -24726
- package/dist/web/mobile-privacy.js +185 -0
- package/dist/web/mobile.css +15 -0
- package/dist/web/mobile.html +21 -1
- package/dist/web/mobile.js +179 -6
- package/dist/web/server.js +633 -0
- package/package.json +2 -2
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills-manager.ts — Skills Manager Runtime 的统一门面 (2026-09-16, 批次 2-G.1)
|
|
3
|
+
*
|
|
4
|
+
* 之前的问题: 技能能力被拆成四个各自为政的入口 (skill-loader 扫描 / skill-share 打包分享 /
|
|
5
|
+
* skill-writer 生成 / skill-organizer 整理), 谁也不知道"这个技能现在到底是什么状态、来自哪里、能不能用",
|
|
6
|
+
* CLI / Web / agent 各看各的。长期执行的 Goal 因此无法固定"我依赖的技能是哪一版"。
|
|
7
|
+
*
|
|
8
|
+
* 这一层做的事 (2-G.1 范围):
|
|
9
|
+
* - **统一事实模型**: 每个技能一条 `SkillRecord` (skillId/name/version/contentHash/source/sourceRef/
|
|
10
|
+
* status/trust/compatibility/installedAt/updatedAt);
|
|
11
|
+
* - **统一入口**: discover / inspect / install / import / enable / disable / validate / resolve /
|
|
12
|
+
* snapshot / health / export —— CLI / Web / Supervisor / agent 都只走这里, 不再各自直调底层模块;
|
|
13
|
+
* - **内容真值仍是 SKILL.md**, 管理元数据落在 `~/.bolloon/skills-registry.json` (不让 loader/share/writer 各自推断)。
|
|
14
|
+
*
|
|
15
|
+
* 2-G.1 **刻意不改执行行为**: enable/disable/status 只被记录与展示; 真正用它拦执行 (readiness gate)
|
|
16
|
+
* 与 Goal 级 skill snapshot 属 2-G.2/2-G.4。
|
|
17
|
+
*/
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
import * as fsp from 'fs/promises';
|
|
21
|
+
import * as crypto from 'crypto';
|
|
22
|
+
// 2026-09-16 (2-G.3): 事务型导入复用底层真实实现 (别名避免与上面重名)
|
|
23
|
+
import { parseSkillBundle as parseBundleLoose, parseSkillRef as parseSkillRefLoose, fetchSkillBundle as fetchBundleLoose } from './skill-share.js';
|
|
24
|
+
import { sanitizeSkillName as sanitizeNameLoose, getUserSkillsDir as userSkillsDirLoose, getProjectSkillsDir as projectSkillsDirLoose } from './skill-writer.js';
|
|
25
|
+
import { parseSkillFile as parseSkillFileLoose } from './skill-loader.js';
|
|
26
|
+
import { parseSkillFile, defaultSkillPaths } from './skill-loader.js';
|
|
27
|
+
import { getUserSkillsDir } from './skill-writer.js';
|
|
28
|
+
import { collectSkillBundle, parseSkillBundle, parseSkillRef, fetchSkillBundle, installSkillBundle, } from './skill-share.js';
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
export function registryPath(home = os.homedir()) {
|
|
31
|
+
return path.join(home, '.bolloon', 'skills-registry.json');
|
|
32
|
+
}
|
|
33
|
+
async function readRegistry(home) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(await fsp.readFile(registryPath(home), 'utf8'));
|
|
36
|
+
if (raw && typeof raw.skills === 'object' && raw.skills)
|
|
37
|
+
return raw;
|
|
38
|
+
}
|
|
39
|
+
catch { /* 缺文件/坏文件 → 空 registry (不阻挡发现) */ }
|
|
40
|
+
return { schema: 'bolloon-skills-registry/1', skills: {}, updatedAt: new Date(0).toISOString() };
|
|
41
|
+
}
|
|
42
|
+
async function writeRegistry(reg, home) {
|
|
43
|
+
const p = registryPath(home);
|
|
44
|
+
await fsp.mkdir(path.dirname(p), { recursive: true });
|
|
45
|
+
reg.updatedAt = new Date().toISOString();
|
|
46
|
+
const tmp = `${p}.tmp`;
|
|
47
|
+
await fsp.writeFile(tmp, JSON.stringify(reg, null, 2), 'utf8');
|
|
48
|
+
await fsp.rename(tmp, p); // 原子替换
|
|
49
|
+
}
|
|
50
|
+
/** 技能目录内容摘要: 排序后逐文件 sha256 → 再摘要一次 (目录整体指纹) */
|
|
51
|
+
export async function hashSkillDir(dir) {
|
|
52
|
+
const issues = [];
|
|
53
|
+
const files = [];
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
const walk = async (d, prefix) => {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fsp.readdir(d, { withFileTypes: true });
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
issues.push(`目录不可读: ${String(err?.message || err).slice(0, 80)}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
65
|
+
if (e.name.startsWith('.'))
|
|
66
|
+
continue;
|
|
67
|
+
const abs = path.join(d, e.name);
|
|
68
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
69
|
+
if (e.isSymbolicLink()) {
|
|
70
|
+
issues.push(`符号链接被跳过 (避免越界): ${rel}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (e.isDirectory()) {
|
|
74
|
+
await walk(abs, rel);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!e.isFile())
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const buf = await fsp.readFile(abs);
|
|
81
|
+
bytes += buf.length;
|
|
82
|
+
files.push({ rel, content: buf });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
issues.push(`文件不可读: ${rel} (${String(err?.message || err).slice(0, 60)})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
await walk(dir, '');
|
|
90
|
+
const h = crypto.createHash('sha256');
|
|
91
|
+
for (const f of files) {
|
|
92
|
+
h.update(f.rel);
|
|
93
|
+
h.update('\0');
|
|
94
|
+
h.update(crypto.createHash('sha256').update(f.content).digest());
|
|
95
|
+
}
|
|
96
|
+
return { hash: h.digest('hex').slice(0, 32), fileCount: files.length, bytes, issues };
|
|
97
|
+
}
|
|
98
|
+
/** 单条记录的结构校验 (纯函数, 可单测) */
|
|
99
|
+
export function validateSkillRecord(input) {
|
|
100
|
+
const issues = [...(input.issues || [])];
|
|
101
|
+
if (!input.name || !/^[a-z0-9_-]{1,64}$/i.test(input.name))
|
|
102
|
+
issues.push('技能名非法 (只允许字母数字下划线连字符, ≤64)');
|
|
103
|
+
if (!input.description || input.description.trim().length < 4)
|
|
104
|
+
issues.push('缺少 description (SKILL.md frontmatter)');
|
|
105
|
+
if (!input.body || input.body.trim().length < 20)
|
|
106
|
+
issues.push('正文内容过少 (可能不是有效的 SKILL.md)');
|
|
107
|
+
if (input.bytes > 2 * 1024 * 1024)
|
|
108
|
+
issues.push(`技能体积过大 (${Math.round(input.bytes / 1024)}KB > 2MB)`);
|
|
109
|
+
const fmStatus = String(input.frontmatter?.status ?? 'active');
|
|
110
|
+
if (!['active', 'archived', 'draft'].includes(fmStatus))
|
|
111
|
+
issues.push(`frontmatter.status 非法: ${fmStatus}`);
|
|
112
|
+
return issues;
|
|
113
|
+
}
|
|
114
|
+
export class SkillsManager {
|
|
115
|
+
home;
|
|
116
|
+
cwd;
|
|
117
|
+
cache = null;
|
|
118
|
+
/** 同名技能出现在哪些目录 (discover 时记录; 同名会被覆盖成一条记录, 重复必须单独留痕) */
|
|
119
|
+
dirsByName = new Map();
|
|
120
|
+
constructor(opts = {}) {
|
|
121
|
+
this.home = opts.home ?? os.homedir();
|
|
122
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
123
|
+
}
|
|
124
|
+
/** 技能搜索路径 (去重, 顺序 = 优先级从低到高) */
|
|
125
|
+
skillDirs() {
|
|
126
|
+
const out = [
|
|
127
|
+
{ dir: path.join(this.cwd, '.bolloon', 'skills'), source: 'project' },
|
|
128
|
+
{ dir: getUserSkillsDir(this.home), source: 'user' },
|
|
129
|
+
];
|
|
130
|
+
for (const p of defaultSkillPaths(this.home, this.cwd)) {
|
|
131
|
+
if (!out.some((x) => x.dir === p))
|
|
132
|
+
out.push({ dir: p, source: 'user' });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** 扫描所有技能目录 + registry → 统一视图 (同名: 后者覆盖前者, 与 loader 语义一致) */
|
|
137
|
+
async discover(opts = {}) {
|
|
138
|
+
const home = opts.home ?? this.home;
|
|
139
|
+
const reg = await readRegistry(home);
|
|
140
|
+
const byName = new Map();
|
|
141
|
+
const dirsByName = new Map();
|
|
142
|
+
for (const { dir, source } of this.skillDirs()) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
152
|
+
continue;
|
|
153
|
+
const skillDir = path.join(dir, e.name);
|
|
154
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
155
|
+
const meta = await parseSkillFile(skillFile).catch(() => null);
|
|
156
|
+
const counted = await hashSkillDir(skillDir);
|
|
157
|
+
if (!meta) {
|
|
158
|
+
// 目录在但不是有效技能: 也进视图 (状态 invalid), 不许静默消失
|
|
159
|
+
const rec = this.buildRecord({
|
|
160
|
+
name: e.name, description: '', version: '0.0.0', frontmatter: {}, body: '',
|
|
161
|
+
dir: skillDir, skillFile, source, hash: counted.hash, fileCount: counted.fileCount,
|
|
162
|
+
bytes: counted.bytes, issues: [`SKILL.md 缺失或无法解析`, ...counted.issues], reg,
|
|
163
|
+
});
|
|
164
|
+
byName.set(rec.name, rec);
|
|
165
|
+
dirsByName.set(rec.name, [...(dirsByName.get(rec.name) || []), skillDir]);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const rec = this.buildRecord({
|
|
169
|
+
name: meta.name || e.name, description: meta.description, version: String(meta.frontmatter?.version ?? '0.0.0'),
|
|
170
|
+
frontmatter: meta.frontmatter, body: meta.body, dir: skillDir, skillFile, source,
|
|
171
|
+
hash: counted.hash, fileCount: counted.fileCount, bytes: counted.bytes, issues: counted.issues, reg, tier: meta.tier, triggers: meta.triggers,
|
|
172
|
+
});
|
|
173
|
+
byName.set(rec.name, rec);
|
|
174
|
+
dirsByName.set(rec.name, [...(dirsByName.get(rec.name) || []), skillDir]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const records = Array.from(byName.values());
|
|
178
|
+
this.cache = records;
|
|
179
|
+
this.dirsByName = dirsByName;
|
|
180
|
+
if (opts.writeRegistry !== false) {
|
|
181
|
+
// 首次发现把缺记录补进 registry (内容 hash 作为基线), 以后才能检出漂移
|
|
182
|
+
let changed = false;
|
|
183
|
+
for (const r of records) {
|
|
184
|
+
if (!reg.skills[r.name]) {
|
|
185
|
+
reg.skills[r.name] = {
|
|
186
|
+
skillId: r.skillId, name: r.name, version: r.version, contentHash: r.contentHash,
|
|
187
|
+
source: r.source, sourceRef: r.dir, status: r.status, trust: r.trust,
|
|
188
|
+
installedAt: r.installedAt, updatedAt: r.updatedAt,
|
|
189
|
+
};
|
|
190
|
+
changed = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (changed)
|
|
194
|
+
await writeRegistry(reg, home).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
return records;
|
|
197
|
+
}
|
|
198
|
+
buildRecord(input) {
|
|
199
|
+
const prior = input.reg.skills[input.name];
|
|
200
|
+
const issues = validateSkillRecord({
|
|
201
|
+
name: input.name, description: input.description, body: input.body,
|
|
202
|
+
frontmatter: input.frontmatter, bytes: input.bytes, issues: input.issues,
|
|
203
|
+
});
|
|
204
|
+
const fmStatus = String(input.frontmatter?.status ?? 'active');
|
|
205
|
+
// 状态优先级: 结构坏 → invalid; registry 显式停用/隔离/归档 → 尊重; 否则按 frontmatter/来源推导
|
|
206
|
+
let status;
|
|
207
|
+
if (issues.length)
|
|
208
|
+
status = 'invalid';
|
|
209
|
+
else if (prior?.status === 'disabled' || prior?.status === 'quarantined' || prior?.status === 'archived')
|
|
210
|
+
status = prior.status;
|
|
211
|
+
else if (fmStatus === 'archived')
|
|
212
|
+
status = 'archived';
|
|
213
|
+
else if (fmStatus === 'draft')
|
|
214
|
+
status = 'discovered';
|
|
215
|
+
else
|
|
216
|
+
status = prior?.status === 'installed' ? 'installed' : 'enabled';
|
|
217
|
+
const source = prior?.source
|
|
218
|
+
|| (input.source === 'project' ? 'project' : 'user');
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
return {
|
|
221
|
+
skillId: prior?.skillId || input.name,
|
|
222
|
+
name: input.name,
|
|
223
|
+
description: input.description,
|
|
224
|
+
version: input.version,
|
|
225
|
+
contentHash: input.hash,
|
|
226
|
+
source,
|
|
227
|
+
sourceRef: prior?.sourceRef || input.dir,
|
|
228
|
+
status,
|
|
229
|
+
trust: prior?.trust || 'unverified',
|
|
230
|
+
compatibility: input.frontmatter?.compatibility ? String(input.frontmatter.compatibility) : undefined,
|
|
231
|
+
tier: input.tier || 'utility',
|
|
232
|
+
triggers: input.triggers || [],
|
|
233
|
+
skillFile: input.skillFile,
|
|
234
|
+
dir: input.dir,
|
|
235
|
+
fileCount: input.fileCount,
|
|
236
|
+
bytes: input.bytes,
|
|
237
|
+
installedAt: prior?.installedAt || now,
|
|
238
|
+
updatedAt: now,
|
|
239
|
+
issues,
|
|
240
|
+
registryHash: prior?.contentHash,
|
|
241
|
+
approvedBy: prior?.approvedBy,
|
|
242
|
+
approvedAt: prior?.approvedAt,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** 单技能详情 (含正文长度与漂移判定) */
|
|
246
|
+
async inspect(name, opts = {}) {
|
|
247
|
+
const all = this.cache && !opts.home ? this.cache : await this.discover(opts);
|
|
248
|
+
return all.find((s) => s.name === name) || null;
|
|
249
|
+
}
|
|
250
|
+
/** 解析一组技能名 → 记录 (缺哪个说清); 2-G.2 的 readiness gate 就用这个 */
|
|
251
|
+
async resolve(names, opts = {}) {
|
|
252
|
+
const all = await this.discover(opts);
|
|
253
|
+
const resolved = [];
|
|
254
|
+
const missing = [];
|
|
255
|
+
const notEnabled = [];
|
|
256
|
+
for (const n of names) {
|
|
257
|
+
const hit = all.find((s) => s.name === n);
|
|
258
|
+
if (!hit) {
|
|
259
|
+
missing.push(n);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (hit.status !== 'enabled' && hit.status !== 'installed')
|
|
263
|
+
notEnabled.push(n);
|
|
264
|
+
resolved.push(hit);
|
|
265
|
+
}
|
|
266
|
+
return { ok: missing.length === 0 && notEnabled.length === 0, resolved, missing, notEnabled };
|
|
267
|
+
}
|
|
268
|
+
/** 版本固定的技能快照 (Goal 级固定版本用; resolvedAt 记录解析时刻) */
|
|
269
|
+
async snapshot(names, opts = {}) {
|
|
270
|
+
const r = await this.resolve(names, opts);
|
|
271
|
+
const resolvedAt = new Date().toISOString();
|
|
272
|
+
return {
|
|
273
|
+
ok: r.missing.length === 0,
|
|
274
|
+
missing: r.missing,
|
|
275
|
+
entries: r.resolved.map((s) => ({ name: s.name, version: s.version, contentHash: s.contentHash, source: s.source, resolvedAt })),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** 健康检查: 状态/来源分布 + 漂移 + 不合格 + 重复 + registry 缺盘 */
|
|
279
|
+
async health(opts = {}) {
|
|
280
|
+
const home = opts.home ?? this.home;
|
|
281
|
+
const all = await this.discover(opts);
|
|
282
|
+
const reg = await readRegistry(home);
|
|
283
|
+
const byStatus = {};
|
|
284
|
+
const bySource = {};
|
|
285
|
+
const drifted = [];
|
|
286
|
+
const invalid = [];
|
|
287
|
+
const duplicates = [];
|
|
288
|
+
for (const s of all) {
|
|
289
|
+
byStatus[s.status] = (byStatus[s.status] || 0) + 1;
|
|
290
|
+
bySource[s.source] = (bySource[s.source] || 0) + 1;
|
|
291
|
+
if (s.issues.length)
|
|
292
|
+
invalid.push({ name: s.name, issues: s.issues });
|
|
293
|
+
const prior = reg.skills[s.name];
|
|
294
|
+
if (prior?.contentHash && prior.contentHash !== s.contentHash) {
|
|
295
|
+
drifted.push({ name: s.name, expected: prior.contentHash, actual: s.contentHash });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const [name, dirs] of this.dirsByName) {
|
|
299
|
+
const uniq = Array.from(new Set(dirs));
|
|
300
|
+
if (uniq.length > 1)
|
|
301
|
+
duplicates.push({ name, dirs: uniq });
|
|
302
|
+
}
|
|
303
|
+
const missing = Object.keys(reg.skills).filter((n) => !all.some((s) => s.name === n));
|
|
304
|
+
return { total: all.length, byStatus, bySource, drifted, invalid, duplicates, missing };
|
|
305
|
+
}
|
|
306
|
+
// ── 管理动作 (2-G.1: 只改状态与账, 不改执行行为) ──────────────────────────
|
|
307
|
+
async patchRegistry(name, patch, home) {
|
|
308
|
+
const h = home ?? this.home;
|
|
309
|
+
const rec = await this.inspect(name, { home: h });
|
|
310
|
+
if (!rec)
|
|
311
|
+
return null;
|
|
312
|
+
const reg = await readRegistry(h);
|
|
313
|
+
reg.skills[name] = { ...(reg.skills[name] || {}), ...patch, name };
|
|
314
|
+
await writeRegistry(reg, h);
|
|
315
|
+
this.cache = null;
|
|
316
|
+
const after = await this.inspect(name, { home: h });
|
|
317
|
+
return after;
|
|
318
|
+
}
|
|
319
|
+
async enable(name, opts = {}) {
|
|
320
|
+
const rec = await this.inspect(name, opts);
|
|
321
|
+
if (!rec)
|
|
322
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
323
|
+
if (rec.issues.length)
|
|
324
|
+
return { ok: false, reason: `技能不合格, 不能启用: ${rec.issues.join('; ')}` };
|
|
325
|
+
const skill = await this.patchRegistry(name, { status: 'enabled' }, opts.home);
|
|
326
|
+
// 2-G.4: 技能又能用了 → 重评被它拦住的 Goal (回 active, 等 Supervisor 继续)
|
|
327
|
+
await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'enable', name }).catch(() => null);
|
|
328
|
+
return { ok: true, skill: skill || undefined };
|
|
329
|
+
}
|
|
330
|
+
async disable(name, opts = {}) {
|
|
331
|
+
const rec = await this.inspect(name, opts);
|
|
332
|
+
if (!rec)
|
|
333
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
334
|
+
const skill = await this.patchRegistry(name, { status: 'disabled' }, opts.home);
|
|
335
|
+
// 2-G.4: 记录"谁依赖它" (不打断当前 Run; 下一次 Run 前由 2-G.2 门禁拦)
|
|
336
|
+
try {
|
|
337
|
+
const { markDependentsOfSkill } = await import('./skill-supervisor-link.js');
|
|
338
|
+
const affected = await markDependentsOfSkill(name, { home: opts.home ?? this.home, reason: '技能被禁用' });
|
|
339
|
+
if (affected.length)
|
|
340
|
+
console.warn(`[skills] ${name} 被禁用; 依赖它的 Goal: ${affected.join(', ')} (下一次 Run 前会门禁)`);
|
|
341
|
+
}
|
|
342
|
+
catch { /* 联动失败不影响禁用结果 */ }
|
|
343
|
+
return { ok: true, skill: skill || undefined };
|
|
344
|
+
}
|
|
345
|
+
/** 人工批准 (信任等级 verified); 2-G.3 的 import 事务会要求它才算"可用" */
|
|
346
|
+
async approve(name, by = 'human', opts = {}) {
|
|
347
|
+
const rec = await this.inspect(name, opts);
|
|
348
|
+
if (!rec)
|
|
349
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
350
|
+
const skill = await this.patchRegistry(name, { trust: 'verified', approvedBy: by, approvedAt: new Date().toISOString() }, opts.home);
|
|
351
|
+
return { ok: true, skill: skill || undefined };
|
|
352
|
+
}
|
|
353
|
+
/** 隔离 (损坏/不可信来源); 被隔离的技能不允许长期 Goal 自动使用 */
|
|
354
|
+
async quarantine(name, reason, opts = {}) {
|
|
355
|
+
const rec = await this.inspect(name, opts);
|
|
356
|
+
if (!rec)
|
|
357
|
+
return { ok: false };
|
|
358
|
+
const reg = await readRegistry(opts.home ?? this.home);
|
|
359
|
+
reg.skills[name] = {
|
|
360
|
+
...(reg.skills[name] || {}), name,
|
|
361
|
+
status: 'quarantined', trust: 'quarantined',
|
|
362
|
+
quarantineReason: reason.slice(0, 200), quarantinedAt: new Date().toISOString(),
|
|
363
|
+
};
|
|
364
|
+
await writeRegistry(reg, opts.home ?? this.home);
|
|
365
|
+
this.cache = null;
|
|
366
|
+
// 2-G.4: 隔离 = 不再可信 → 依赖它的 Goal 下一次 Run 前必然门禁失败
|
|
367
|
+
try {
|
|
368
|
+
const { markDependentsOfSkill } = await import('./skill-supervisor-link.js');
|
|
369
|
+
const affected = await markDependentsOfSkill(name, { home: opts.home ?? this.home, reason: `技能被隔离: ${reason}` });
|
|
370
|
+
if (affected.length)
|
|
371
|
+
console.warn(`[skills] ${name} 被隔离; 依赖它的 Goal: ${affected.join(', ')}`);
|
|
372
|
+
}
|
|
373
|
+
catch { /* 联动失败不影响隔离结果 */ }
|
|
374
|
+
return { ok: true, skill: (await this.inspect(name, opts)) || undefined };
|
|
375
|
+
}
|
|
376
|
+
/** 结构校验: 重算问题清单并把状态落成 invalid (或从 invalid 恢复) */
|
|
377
|
+
async validate(name, opts = {}) {
|
|
378
|
+
const rec = await this.inspect(name, opts);
|
|
379
|
+
if (!rec)
|
|
380
|
+
return { ok: false, issues: [`没有这个技能: ${name}`] };
|
|
381
|
+
const skill = await this.patchRegistry(name, { status: rec.issues.length ? 'invalid' : (rec.status === 'invalid' ? 'enabled' : rec.status) }, opts.home);
|
|
382
|
+
return { ok: rec.issues.length === 0, issues: rec.issues, skill: skill || undefined };
|
|
383
|
+
}
|
|
384
|
+
/** 导出技能包 (复用 skill-share 的打包, 不重复实现) */
|
|
385
|
+
async export(name, opts = {}) {
|
|
386
|
+
const rec = await this.inspect(name, opts);
|
|
387
|
+
if (!rec)
|
|
388
|
+
return { ok: false, error: `没有这个技能: ${name}` };
|
|
389
|
+
return collectSkillBundle(rec.dir, { name: rec.name });
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* 导入技能 (对话/链接/CID 三种写法都支持)。
|
|
393
|
+
* **2-G.1 只做统一入口 + 记账**: 下载 → 安装仍走现有 installSkillBundle (已有路径穿越校验 / 版本门 / 备份),
|
|
394
|
+
* 事务化 (临时目录 + 原子移动 + hash 校验) 属 2-G.3。
|
|
395
|
+
*/
|
|
396
|
+
async import(ref, opts = {}) {
|
|
397
|
+
// 2026-09-16 (2-G.3): 统一走事务版 (暂存 → 原子替换 → 校验 → 回滚), 失败不污染当前技能环境
|
|
398
|
+
const tx = await this.importTransactional({ ref }, opts);
|
|
399
|
+
if (tx.ok)
|
|
400
|
+
await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'import', name: tx.name }).catch(() => null);
|
|
401
|
+
return { ok: tx.ok, name: tx.name, version: tx.version, error: tx.error, skill: tx.skill };
|
|
402
|
+
}
|
|
403
|
+
/** @deprecated 保留旧签名以兼容; 内部已走事务版 */
|
|
404
|
+
async importLegacy(ref, opts = {}) {
|
|
405
|
+
const cid = parseSkillRef(ref);
|
|
406
|
+
if (!cid)
|
|
407
|
+
return { ok: false, error: `无法识别的技能引用 (要 bolloon://skill/<cid> / ipfs://<cid> / 裸 CID): ${ref.slice(0, 60)}` };
|
|
408
|
+
const fetched = await fetchSkillBundle(cid);
|
|
409
|
+
if (!fetched.ok || !fetched.bundle)
|
|
410
|
+
return { ok: false, error: fetched.error || '取包失败' };
|
|
411
|
+
const inst = await installSkillBundle(fetched.bundle, { home: opts.home ?? this.home, cwd: opts.cwd ?? this.cwd, force: opts.force, scope: opts.scope });
|
|
412
|
+
if (!inst.ok)
|
|
413
|
+
return { ok: false, error: inst.error };
|
|
414
|
+
const h = opts.home ?? this.home;
|
|
415
|
+
const name = fetched.bundle.name;
|
|
416
|
+
this.cache = null;
|
|
417
|
+
const after = await this.inspect(name, { home: h });
|
|
418
|
+
if (after) {
|
|
419
|
+
await this.patchRegistry(name, {
|
|
420
|
+
status: 'installed', source: opts.source || 'shared', sourceRef: cid, trust: 'unverified',
|
|
421
|
+
contentHash: after.contentHash, version: after.version,
|
|
422
|
+
}, h);
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, name, version: fetched.bundle.version, skill: (await this.inspect(name, { home: h })) || undefined };
|
|
425
|
+
}
|
|
426
|
+
/** 从已解析的技能包装入 (本地文件/已取到的包) */
|
|
427
|
+
async install(bundleJson, opts = {}) {
|
|
428
|
+
// 2026-09-16 (2-G.3): 同样走事务 (install 的默认来源保持 'imported')
|
|
429
|
+
const tx = await this.importTransactional({ bundleJson }, { ...opts, source: opts.source || 'imported' });
|
|
430
|
+
if (tx.ok)
|
|
431
|
+
await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'install', name: tx.name }).catch(() => null);
|
|
432
|
+
return { ok: tx.ok, name: tx.name, error: tx.error, skill: tx.skill };
|
|
433
|
+
}
|
|
434
|
+
/** @deprecated 旧的"直接写正式目录"实现 (保留对照, 不再被 import/install 调用) */
|
|
435
|
+
async installLegacy(bundleJson, opts = {}) {
|
|
436
|
+
const parsed = parseSkillBundle(bundleJson);
|
|
437
|
+
if (!parsed.ok || !parsed.bundle)
|
|
438
|
+
return { ok: false, error: parsed.error || '包格式非法' };
|
|
439
|
+
const inst = await installSkillBundle(parsed.bundle, { home: opts.home ?? this.home, cwd: opts.cwd ?? this.cwd, force: opts.force });
|
|
440
|
+
if (!inst.ok)
|
|
441
|
+
return { ok: false, error: inst.error };
|
|
442
|
+
const h = opts.home ?? this.home;
|
|
443
|
+
this.cache = null;
|
|
444
|
+
const after = await this.inspect(parsed.bundle.name, { home: h });
|
|
445
|
+
if (after) {
|
|
446
|
+
await this.patchRegistry(parsed.bundle.name, {
|
|
447
|
+
status: 'installed', source: opts.source || 'imported', sourceRef: opts.sourceRef || 'local-bundle',
|
|
448
|
+
trust: 'unverified', contentHash: after.contentHash, version: after.version,
|
|
449
|
+
}, h);
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, name: parsed.bundle.name, skill: (await this.inspect(parsed.bundle.name, { home: h })) || undefined };
|
|
452
|
+
}
|
|
453
|
+
// ── 事务型导入 (2-G.3, 2026-09-16) ───────────────────────────────────────
|
|
454
|
+
/**
|
|
455
|
+
* 把 import/install 变成**事务**:
|
|
456
|
+
* 读来源 → 预备校验 (名字/路径穿越/SKILL.md frontmatter/版本门) → 写暂存目录
|
|
457
|
+
* → 原子替换 (旧目录先改名保留) → 校验落地结果 → 更新 registry
|
|
458
|
+
* 任何一步失败: 正式目录不变 · registry 不变 · 暂存清理 · 失败原因可查询 (importHistory)。
|
|
459
|
+
* 中途被 SIGKILL: 只可能留下 `.<name>-staging-*` 暂存或 `.<name>.bak-*` 备份 →
|
|
460
|
+
* recoverInterruptedImports() 会清理暂存并把备份恢复回正式位置。
|
|
461
|
+
*/
|
|
462
|
+
async importTransactional(input, opts = {}) {
|
|
463
|
+
const h = opts.home ?? this.home;
|
|
464
|
+
const started = Date.now();
|
|
465
|
+
const fail = async (step, error, name) => {
|
|
466
|
+
await this.recordImportFailure({ step, error, name, ref: input.ref, at: new Date().toISOString() });
|
|
467
|
+
return { ok: false, error, step, name };
|
|
468
|
+
};
|
|
469
|
+
// 0) 先清上一次中断留下的暂存/备份, 避免互相干扰
|
|
470
|
+
await this.recoverInterruptedImports({ home: h }).catch(() => null);
|
|
471
|
+
// 1) 取包 (ref 走 IPFS; bundleJson 直接用)
|
|
472
|
+
let bundle = null;
|
|
473
|
+
let sourceRef = 'local-bundle';
|
|
474
|
+
if (input.bundleJson) {
|
|
475
|
+
const parsed = parseBundleLoose(input.bundleJson);
|
|
476
|
+
if (!parsed?.ok || !parsed.bundle)
|
|
477
|
+
return await fail('parse', parsed?.error || '包格式非法 (不是 bolloon-skill-bundle/1 JSON)');
|
|
478
|
+
bundle = parsed.bundle;
|
|
479
|
+
sourceRef = 'local-bundle';
|
|
480
|
+
}
|
|
481
|
+
else if (input.ref) {
|
|
482
|
+
const cid = parseSkillRefLoose(input.ref);
|
|
483
|
+
if (!cid)
|
|
484
|
+
return await fail('parse', `无法识别的技能引用: ${String(input.ref).slice(0, 60)}`);
|
|
485
|
+
const fetched = await fetchBundleLoose(cid);
|
|
486
|
+
if (!fetched?.ok || !fetched.bundle)
|
|
487
|
+
return await fail('fetch', fetched?.error || '取包失败');
|
|
488
|
+
bundle = fetched.bundle;
|
|
489
|
+
sourceRef = cid;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
return await fail('parse', '必须给 ref 或 bundleJson');
|
|
493
|
+
}
|
|
494
|
+
// 2) 预备校验 (全部发生在动正式目录之前)
|
|
495
|
+
if (!bundle || typeof bundle !== 'object')
|
|
496
|
+
return await fail('parse', '包内容不是对象');
|
|
497
|
+
const name = sanitizeNameLoose(bundle.name);
|
|
498
|
+
if (!name)
|
|
499
|
+
return await fail('validate', '技能名非法 (清洗后为空)');
|
|
500
|
+
if (!bundle.files || typeof bundle.files !== 'object')
|
|
501
|
+
return await fail('validate', '包内没有 files', name);
|
|
502
|
+
const skillMd = bundle.files['SKILL.md'];
|
|
503
|
+
if (typeof skillMd !== 'string' || !skillMd.trim())
|
|
504
|
+
return await fail('validate', '包内缺少 SKILL.md', name);
|
|
505
|
+
const fm = parseFrontmatterLoose(skillMd);
|
|
506
|
+
if (!fm)
|
|
507
|
+
return await fail('validate', 'SKILL.md 缺少合法 frontmatter (--- 包裹)', name);
|
|
508
|
+
if (!fm.name)
|
|
509
|
+
return await fail('validate', 'SKILL.md frontmatter 缺 name', name);
|
|
510
|
+
for (const rel of Object.keys(bundle.files)) {
|
|
511
|
+
const norm = path.normalize(rel).replace(/^([/\\])+/, '');
|
|
512
|
+
if (norm.startsWith('..') || path.isAbsolute(norm))
|
|
513
|
+
return await fail('validate', `技能包含非法路径 (路径穿越): ${rel}`, name);
|
|
514
|
+
}
|
|
515
|
+
const base = opts.scope === 'project' ? projectSkillsDirLoose(opts.cwd ?? this.cwd) : userSkillsDirLoose(h);
|
|
516
|
+
const targetDir = path.join(base, name);
|
|
517
|
+
const existingMeta = await parseSkillFileLoose(path.join(targetDir, 'SKILL.md'));
|
|
518
|
+
const existingVersion = String(existingMeta?.frontmatter?.version ?? '0.0.0');
|
|
519
|
+
const incomingVersion = String(bundle.version || fm.version || '0.0.0');
|
|
520
|
+
if (existingMeta && cmpVersionLoose(existingVersion, incomingVersion) >= 0 && !opts.force) {
|
|
521
|
+
return await fail('version', `本地已有 ${name}@${existingVersion}, 来的是 ${incomingVersion} (不更新; force=true 可强制)`, name);
|
|
522
|
+
}
|
|
523
|
+
// 3) 写暂存目录 (同名 . 前缀 → discover 不会当成技能)
|
|
524
|
+
const staging = path.join(base, `.${name}-staging-${started}`);
|
|
525
|
+
await fsp.mkdir(staging, { recursive: true });
|
|
526
|
+
try {
|
|
527
|
+
for (const [rel, content] of Object.entries(bundle.files)) {
|
|
528
|
+
const norm = path.normalize(rel).replace(/^([/\\])+/, '');
|
|
529
|
+
const abs = path.join(staging, norm);
|
|
530
|
+
await fsp.mkdir(path.dirname(abs), { recursive: true });
|
|
531
|
+
await fsp.writeFile(abs, String(content), 'utf-8');
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
await fsp.rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
536
|
+
return await fail('stage', `写暂存失败: ${String(err?.message || err).slice(0, 160)}`, name);
|
|
537
|
+
}
|
|
538
|
+
// 4) 原子替换: 旧目录改名保留 (备份), 暂存改名就位
|
|
539
|
+
let backup;
|
|
540
|
+
try {
|
|
541
|
+
const exists = await fsp.stat(targetDir).then(() => true).catch(() => false);
|
|
542
|
+
if (exists) {
|
|
543
|
+
backup = path.join(base, `.${name}.bak-${started}`);
|
|
544
|
+
await fsp.rename(targetDir, backup);
|
|
545
|
+
}
|
|
546
|
+
await fsp.rename(staging, targetDir);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
// 回滚: 备份放回, 暂存清掉
|
|
550
|
+
await fsp.rm(staging, { recursive: true, force: true }).catch(() => { });
|
|
551
|
+
if (backup)
|
|
552
|
+
await fsp.rename(backup, targetDir).catch(() => { });
|
|
553
|
+
return await fail('swap', `原子替换失败 (已回滚): ${String(err?.message || err).slice(0, 160)}`, name);
|
|
554
|
+
}
|
|
555
|
+
// 5) 校验落地结果 (读回来再确认一次)
|
|
556
|
+
// 回滚条件只看**结构性失败** (SKILL.md 解析不出来); 内容质量类提示 (如正文过少) 只记警告 ——
|
|
557
|
+
// 否则用户自己的"简洁技能"永远装不回来 (导出→安装 自洽被打破)。
|
|
558
|
+
this.cache = null;
|
|
559
|
+
const parsedBack = await parseSkillFileLoose(path.join(targetDir, 'SKILL.md')).catch(() => null);
|
|
560
|
+
const after = await this.inspect(name, { home: h }).catch(() => null);
|
|
561
|
+
if (!parsedBack) {
|
|
562
|
+
await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => { });
|
|
563
|
+
if (backup)
|
|
564
|
+
await fsp.rename(backup, targetDir).catch(() => { });
|
|
565
|
+
return await fail('verify', `落地校验失败 (已回滚): SKILL.md 装完解析不出来`, name);
|
|
566
|
+
}
|
|
567
|
+
if (after && (after.issues || []).length) {
|
|
568
|
+
await appendImportHistory(h, { at: new Date().toISOString(), ok: true, kind: 'warning', name, step: 'verify', warning: (after.issues || []).slice(0, 3).join('; ') }).catch(() => { });
|
|
569
|
+
}
|
|
570
|
+
// 6) registry 更新 (失败不致命: 下次 discover 会重建)
|
|
571
|
+
try {
|
|
572
|
+
await this.patchRegistry(name, {
|
|
573
|
+
status: 'installed', source: opts.source || 'shared', sourceRef,
|
|
574
|
+
trust: 'unverified', contentHash: after?.contentHash || '', version: after?.version || incomingVersion,
|
|
575
|
+
}, h);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
await this.recordImportFailure({ step: 'registry', error: `registry 更新失败: ${String(err?.message || err).slice(0, 140)}`, name, ref: input.ref, at: new Date().toISOString() });
|
|
579
|
+
}
|
|
580
|
+
await this.recordImportSuccess({ name, version: after?.version || incomingVersion, ref: input.ref, backup, at: new Date().toISOString() });
|
|
581
|
+
return { ok: true, name, version: after?.version || incomingVersion, backup, skill: after || undefined };
|
|
582
|
+
}
|
|
583
|
+
// ── 与 Supervisor 的长期联动 (2-G.4, 2026-09-16) ────────────────────────
|
|
584
|
+
/**
|
|
585
|
+
* 技能注册表发生变化 (导入成功 / 启用 / 禁用 / 隔离 / 漂移) → 重新评估**被技能拦住的 Goal**:
|
|
586
|
+
* · 技能又能用了 → 重新冻结快照 + Goal 回 active (等 Supervisor 下一轮继续)
|
|
587
|
+
* · 技能被禁用/隔离/漂移 → 交给 2-G.2 的执行前门禁拦 (这里只把状态标清, 不抢执行权)
|
|
588
|
+
*/
|
|
589
|
+
async onRegistryChanged(opts = { action: 'unknown' }) {
|
|
590
|
+
const h = opts.home ?? this.home;
|
|
591
|
+
try {
|
|
592
|
+
const { reconsiderSkillBlockedGoals } = await import('./skill-supervisor-link.js');
|
|
593
|
+
const res = await reconsiderSkillBlockedGoals({ home: h, action: opts.action, name: opts.name });
|
|
594
|
+
return res;
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
console.warn(`[skills] 联动重评失败 (不影响导入结果): ${String(err?.message || err).slice(0, 140)}`);
|
|
598
|
+
return { rechecked: 0, resumed: [], stillBlocked: [] };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** 清理中断残留: 暂存目录删掉; 备份且正式目录缺失 → 恢复备份 (不丢已装技能) */
|
|
602
|
+
async recoverInterruptedImports(opts = {}) {
|
|
603
|
+
const h = opts.home ?? this.home;
|
|
604
|
+
const removedStaging = [];
|
|
605
|
+
const restored = [];
|
|
606
|
+
for (const base of [userSkillsDirLoose(h), projectSkillsDirLoose(opts.cwd ?? this.cwd)]) {
|
|
607
|
+
let entries = [];
|
|
608
|
+
try {
|
|
609
|
+
entries = await fsp.readdir(base);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
for (const e of entries) {
|
|
615
|
+
if (/^\..*-staging-\d+$/.test(e)) {
|
|
616
|
+
await fsp.rm(path.join(base, e), { recursive: true, force: true }).catch(() => { });
|
|
617
|
+
removedStaging.push(e);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
const m = /^\.(.+)\.bak-(\d+)$/.exec(e);
|
|
621
|
+
if (m) {
|
|
622
|
+
const live = path.join(base, m[1]);
|
|
623
|
+
const liveExists = await fsp.stat(live).then(() => true).catch(() => false);
|
|
624
|
+
if (!liveExists) {
|
|
625
|
+
await fsp.rename(path.join(base, e), live).catch(() => { });
|
|
626
|
+
restored.push(`${m[1]} (from ${e})`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { removedStaging, restored };
|
|
632
|
+
}
|
|
633
|
+
/** 导入历史/失败原因 (CLI/Web 可查) */
|
|
634
|
+
async importHistory(opts = {}) {
|
|
635
|
+
const h = opts.home ?? this.home;
|
|
636
|
+
try {
|
|
637
|
+
const raw = JSON.parse(await fsp.readFile(importHistoryPath(h), 'utf8'));
|
|
638
|
+
return (Array.isArray(raw) ? raw : []).slice(-1 * (opts.limit || 20));
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async recordImportSuccess(rec) {
|
|
645
|
+
await appendImportHistory(this.home, { ...rec, ok: true, kind: 'success' }).catch(() => { });
|
|
646
|
+
}
|
|
647
|
+
async recordImportFailure(rec) {
|
|
648
|
+
await appendImportHistory(this.home, { ...rec, ok: false, kind: 'failure' }).catch(() => { });
|
|
649
|
+
}
|
|
650
|
+
// ── 视图 ─────────────────────────────────────────────────────────────────
|
|
651
|
+
/** 给 CLI / Web / agent 的同一份列表 (字段一致, 顺序一致: name 升序) */
|
|
652
|
+
async view(opts = {}) {
|
|
653
|
+
const all = await this.discover(opts);
|
|
654
|
+
return [...all].sort((a, b) => a.name.localeCompare(b.name));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
let singleton = null;
|
|
658
|
+
/** 进程内单例 (CLI / Web / Supervisor 共用同一个视图实现) */
|
|
659
|
+
export function getSkillsManager(opts = {}) {
|
|
660
|
+
if (!singleton)
|
|
661
|
+
singleton = new SkillsManager(opts);
|
|
662
|
+
return singleton;
|
|
663
|
+
}
|
|
664
|
+
export function resetSkillsManagerForTest() {
|
|
665
|
+
singleton = null;
|
|
666
|
+
}
|
|
667
|
+
/** 一行摘要 (CLI / 日志用) */
|
|
668
|
+
export function formatSkillLine(s) {
|
|
669
|
+
return `${s.name.padEnd(28)} ${String(s.status).padEnd(11)} ${String(s.source).padEnd(9)} ${String(s.trust).padEnd(10)} v${s.version.padEnd(8)} ${s.contentHash.slice(0, 10)}${s.issues.length ? ` ⚠ ${s.issues.length} 个问题` : ''}`;
|
|
670
|
+
}
|
|
671
|
+
// ── 事务型导入的小工具 (2-G.3) ───────────────────────────────────────────────
|
|
672
|
+
function importHistoryPath(home) {
|
|
673
|
+
return path.join(home, '.bolloon', 'skill-imports.json');
|
|
674
|
+
}
|
|
675
|
+
async function appendImportHistory(home, rec) {
|
|
676
|
+
const p = importHistoryPath(home);
|
|
677
|
+
await fsp.mkdir(path.dirname(p), { recursive: true });
|
|
678
|
+
let arr = [];
|
|
679
|
+
try {
|
|
680
|
+
const raw = JSON.parse(await fsp.readFile(p, 'utf8'));
|
|
681
|
+
if (Array.isArray(raw))
|
|
682
|
+
arr = raw;
|
|
683
|
+
}
|
|
684
|
+
catch { /* 首次 */ }
|
|
685
|
+
arr.push(rec);
|
|
686
|
+
await fsp.writeFile(p, JSON.stringify(arr.slice(-50), null, 2), 'utf8');
|
|
687
|
+
}
|
|
688
|
+
/** 极简 frontmatter 解析 (只要 name/version, 用于预备校验) */
|
|
689
|
+
export function parseFrontmatterLoose(text) {
|
|
690
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
|
|
691
|
+
if (!m)
|
|
692
|
+
return null;
|
|
693
|
+
const out = {};
|
|
694
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
695
|
+
const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+)$/.exec(line.trim());
|
|
696
|
+
if (!kv)
|
|
697
|
+
continue;
|
|
698
|
+
const key = kv[1].toLowerCase();
|
|
699
|
+
const val = kv[2].replace(/^["']|["']$/g, '').trim();
|
|
700
|
+
if (key === 'name')
|
|
701
|
+
out.name = val;
|
|
702
|
+
if (key === 'version')
|
|
703
|
+
out.version = val;
|
|
704
|
+
}
|
|
705
|
+
return out;
|
|
706
|
+
}
|
|
707
|
+
/** 版本比较 (semver 数字段; 非法段当 0) */
|
|
708
|
+
export function cmpVersionLoose(a, b) {
|
|
709
|
+
const pa = String(a).split('.').map((x) => Number(String(x).replace(/[^0-9]/g, '')) || 0);
|
|
710
|
+
const pb = String(b).split('.').map((x) => Number(String(x).replace(/[^0-9]/g, '')) || 0);
|
|
711
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
712
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
713
|
+
if (d !== 0)
|
|
714
|
+
return d > 0 ? 1 : -1;
|
|
715
|
+
}
|
|
716
|
+
return 0;
|
|
717
|
+
}
|