@bolloon/bolloon-agent 0.4.25 → 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 +57 -2
- package/dist/agents/external-events.js +162 -0
- package/dist/agents/goal-criteria.js +124 -0
- package/dist/agents/goal-store.js +79 -4
- package/dist/agents/pi-sdk.js +39 -0
- package/dist/agents/skill-readiness.js +133 -0
- package/dist/agents/skill-supervisor-link.js +70 -0
- package/dist/agents/skills-manager.js +282 -0
- package/dist/cli/setup-wizard.js +96 -127
- 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 +121 -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/server.js +248 -1
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-readiness.ts — Goal 级技能快照 + 执行前就绪门禁 (批次 2-G.2, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 定位 (与 leo 的规格一致): 这属于**执行前准备**, 由 Supervisor / runner resolver 负责 ——
|
|
5
|
+
* **不放进 PiAgentHarness** (Harness 只管"这一段能不能安全执行")。
|
|
6
|
+
*
|
|
7
|
+
* 规则:
|
|
8
|
+
* · Goal 首次执行时把 requiredSkills 解析成 snapshot (name/version/contentHash/source/resolvedAt) 并冻结;
|
|
9
|
+
* · 后续 Run 只认快照: 缺技能 / 未启用 / 损坏 / hash 漂移 / 版本变化 → **不启动 Run**, Goal → needs_human;
|
|
10
|
+
* · 前缀 '?' 的技能算可选: 缺失不阻塞, 但写一条 degradation;
|
|
11
|
+
* · **不允许静默用新版本** —— 漂移必须人工批准 (approve) 才会更新快照。
|
|
12
|
+
*/
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import { SkillsManager } from './skills-manager.js';
|
|
15
|
+
import { readGoal, setContinuation, updateGoal, addEvidence, } from './goal-store.js';
|
|
16
|
+
import { recordDegradation } from './run-store.js';
|
|
17
|
+
export function parseSkillSpecs(list = []) {
|
|
18
|
+
const required = [];
|
|
19
|
+
const optional = [];
|
|
20
|
+
for (const raw of list) {
|
|
21
|
+
const n = String(raw || '').trim();
|
|
22
|
+
if (!n)
|
|
23
|
+
continue;
|
|
24
|
+
if (n.startsWith('?'))
|
|
25
|
+
optional.push(n.slice(1).trim());
|
|
26
|
+
else
|
|
27
|
+
required.push(n);
|
|
28
|
+
}
|
|
29
|
+
return { required, optional };
|
|
30
|
+
}
|
|
31
|
+
function userHome(home) {
|
|
32
|
+
return home || process.env.HOME || os.homedir();
|
|
33
|
+
}
|
|
34
|
+
function manager(home) {
|
|
35
|
+
return new SkillsManager({ home: userHome(home), cwd: process.cwd() });
|
|
36
|
+
}
|
|
37
|
+
/** 冻结技能快照 (首次执行时调用; 已冻结则不重复解析) */
|
|
38
|
+
export async function freezeGoalSkills(goal, opts = {}) {
|
|
39
|
+
const { required } = parseSkillSpecs(goal.requiredSkills || []);
|
|
40
|
+
if (!required.length)
|
|
41
|
+
return { ok: true, snapshot: goal.skillSnapshot || [], missing: [], notEnabled: [] };
|
|
42
|
+
if (goal.skillSnapshot?.length && !opts.force)
|
|
43
|
+
return { ok: true, snapshot: goal.skillSnapshot, missing: [], notEnabled: [] };
|
|
44
|
+
const sm = manager(opts.home);
|
|
45
|
+
const res = await sm.snapshot(required, { home: userHome(opts.home) });
|
|
46
|
+
if (!res.ok || res.missing.length) {
|
|
47
|
+
return { ok: false, missing: res.missing, notEnabled: [], reason: `必需技能缺失: ${res.missing.join(', ')}` };
|
|
48
|
+
}
|
|
49
|
+
const snapshot = res.entries.map((e) => ({ name: e.name, version: e.version, contentHash: e.contentHash, source: e.source, resolvedAt: e.resolvedAt }));
|
|
50
|
+
await updateGoal(goal.goalId, { skillSnapshot: snapshot });
|
|
51
|
+
await addEvidence(goal.goalId, [`技能快照已冻结: ${snapshot.map((x) => `${x.name}@${x.version}:${x.contentHash.slice(0, 8)}`).join(', ')}`]).catch(() => { });
|
|
52
|
+
return { ok: true, snapshot, missing: [], notEnabled: [] };
|
|
53
|
+
}
|
|
54
|
+
/** 执行前就绪门禁 (Supervisor 每次准备起 Run 之前调) */
|
|
55
|
+
export async function ensureGoalSkillsReady(goal, opts = {}) {
|
|
56
|
+
const { required, optional } = parseSkillSpecs(goal.requiredSkills || []);
|
|
57
|
+
const out = { ok: true, missing: [], notEnabled: [], invalid: [], drift: [], degradations: [] };
|
|
58
|
+
if (!required.length && !optional.length)
|
|
59
|
+
return out;
|
|
60
|
+
const frozen = await freezeGoalSkills(goal, opts);
|
|
61
|
+
if (!frozen.ok) {
|
|
62
|
+
out.ok = false;
|
|
63
|
+
out.missing = frozen.missing;
|
|
64
|
+
out.reason = frozen.reason || '技能快照无法冻结';
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
const snapshot = frozen.snapshot || [];
|
|
68
|
+
out.snapshot = snapshot;
|
|
69
|
+
// 逐个校验快照 (真实 registry + 真实文件 hash)
|
|
70
|
+
const sm = manager(opts.home);
|
|
71
|
+
const home = userHome(opts.home);
|
|
72
|
+
for (const entry of snapshot) {
|
|
73
|
+
const rec = await sm.inspect(entry.name, { home });
|
|
74
|
+
if (!rec) {
|
|
75
|
+
out.missing.push(entry.name);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
79
|
+
out.notEnabled.push(`${entry.name}(${rec.status})`);
|
|
80
|
+
if (rec.contentHash !== entry.contentHash)
|
|
81
|
+
out.drift.push({ name: entry.name, expected: entry.contentHash, actual: rec.contentHash });
|
|
82
|
+
if (rec.version !== entry.version)
|
|
83
|
+
out.drift.push({ name: entry.name, expected: entry.version, actual: rec.version });
|
|
84
|
+
const v = await sm.validate(entry.name, { home }).catch(() => null);
|
|
85
|
+
if (v && !v.ok)
|
|
86
|
+
out.invalid.push(`${entry.name}: ${(v.issues || []).slice(0, 2).join('; ')}`);
|
|
87
|
+
}
|
|
88
|
+
// 可选技能: 缺失/未启用 → 只记 degradation (不阻塞)
|
|
89
|
+
for (const name of optional) {
|
|
90
|
+
const rec = await sm.inspect(name, { home });
|
|
91
|
+
if (!rec) {
|
|
92
|
+
out.degradations.push(`可选技能 ${name} 不存在 → 继续执行 (已记降级)`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (rec.status !== 'enabled' && rec.status !== 'installed')
|
|
96
|
+
out.degradations.push(`可选技能 ${name} 未启用 (${rec.status}) → 继续执行`);
|
|
97
|
+
}
|
|
98
|
+
if (out.missing.length)
|
|
99
|
+
out.reason = `必需技能缺失: ${out.missing.join(', ')}`;
|
|
100
|
+
else if (out.notEnabled.length)
|
|
101
|
+
out.reason = `必需技能未启用: ${out.notEnabled.join(', ')}`;
|
|
102
|
+
else if (out.invalid.length)
|
|
103
|
+
out.reason = `必需技能损坏: ${out.invalid.join(', ')}`;
|
|
104
|
+
else if (out.drift.length)
|
|
105
|
+
out.reason = `技能内容漂移 (需人工批准才能升级): ${out.drift.map((d) => `${d.name} ${String(d.expected).slice(0, 8)}→${String(d.actual).slice(0, 8)}`).join(', ')}`;
|
|
106
|
+
out.ok = !(out.missing.length || out.notEnabled.length || out.invalid.length || out.drift.length);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** 门禁不过 → 写清事实并把 Goal 交给人 (不启动 Run, 不伪造失败) */
|
|
110
|
+
export async function blockGoalOnSkills(goalId, res) {
|
|
111
|
+
const reason = res.reason || '技能未就绪';
|
|
112
|
+
await setContinuation(goalId, {
|
|
113
|
+
wakeReason: 'needs_human', autoContinue: false, needsExternal: undefined,
|
|
114
|
+
skillReadiness: { ok: false, at: new Date().toISOString(), reason, missing: res.missing, drift: res.drift, degradations: res.degradations },
|
|
115
|
+
});
|
|
116
|
+
await updateGoal(goalId, { status: 'needs_human' }).catch(() => { });
|
|
117
|
+
await addEvidence(goalId, [`技能门禁拦截: ${reason}`]).catch(() => { });
|
|
118
|
+
for (const d of res.degradations)
|
|
119
|
+
await recordDegradation({ kind: 'observational', op: 'skill-readiness', message: d }).catch(() => { });
|
|
120
|
+
}
|
|
121
|
+
/** 人工批准技能升级: 重新冻结快照 (显式动作, 不隐式切换) */
|
|
122
|
+
export async function approveSkillUpgrade(goalId, opts = {}) {
|
|
123
|
+
const goal = await readGoal(goalId);
|
|
124
|
+
if (!goal)
|
|
125
|
+
return { ok: false, reason: 'Goal 不存在' };
|
|
126
|
+
const frozen = await freezeGoalSkills(goal, { ...opts, force: true });
|
|
127
|
+
if (!frozen.ok)
|
|
128
|
+
return { ok: false, reason: frozen.reason };
|
|
129
|
+
await setContinuation(goalId, { skillReadiness: { ok: true, at: new Date().toISOString(), reason: '人工批准技能升级' }, wakeReason: 'active', autoContinue: true });
|
|
130
|
+
await updateGoal(goalId, { status: 'active' }).catch(() => { });
|
|
131
|
+
await addEvidence(goalId, [`技能升级已被人工批准: ${(frozen.snapshot || []).map((s) => `${s.name}@${s.version}`).join(', ')}`]).catch(() => { });
|
|
132
|
+
return { ok: true, snapshot: frozen.snapshot };
|
|
133
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-supervisor-link.ts — 技能状态与长期执行的联动 (批次 2-G.4, 2026-09-16)
|
|
3
|
+
*
|
|
4
|
+
* 规则 (与 leo 的规格一致):
|
|
5
|
+
* · 技能导入成功 / 又能用了 → 被它拦住的 Goal 重新冻结快照并回到 active (等 Supervisor 继续调度);
|
|
6
|
+
* · 技能被禁用 / 隔离 / 内容漂移 → **不打断正在跑的 Run**, 但下一次 Run 之前 readiness 必然失败 (2-G.2 门禁) → needs_human;
|
|
7
|
+
* · 漂移**不允许隐式切换版本**: 继续固定旧快照, 或等人工 approve 升级 (approveSkillUpgrade)。
|
|
8
|
+
*
|
|
9
|
+
* 这里只改"该不该继续"的事实, 不直接启动执行 —— 执行权始终在 Supervisor。
|
|
10
|
+
*/
|
|
11
|
+
import { listGoals } from './goal-store.js';
|
|
12
|
+
import { ensureGoalSkillsReady, blockGoalOnSkills, approveSkillUpgrade, parseSkillSpecs } from './skill-readiness.js';
|
|
13
|
+
import { SkillsManager } from './skills-manager.js';
|
|
14
|
+
import { recordDegradation } from './run-store.js';
|
|
15
|
+
/**
|
|
16
|
+
* 重评所有"因技能被拦"的 Goal。
|
|
17
|
+
* @param opts.action 触发原因 (import / enable / disable / quarantine / drift) —— 只用于事实记录
|
|
18
|
+
*/
|
|
19
|
+
export async function reconsiderSkillBlockedGoals(opts = {}) {
|
|
20
|
+
const out = { rechecked: 0, resumed: [], stillBlocked: [] };
|
|
21
|
+
const home = opts.home;
|
|
22
|
+
const goals = await listGoals({ limit: 200 });
|
|
23
|
+
const interesting = goals.filter((g) => {
|
|
24
|
+
const r = g.continuation?.skillReadiness;
|
|
25
|
+
const specs = parseSkillSpecs(g.requiredSkills || []);
|
|
26
|
+
return (r && r.ok === false) || specs.required.length > 0;
|
|
27
|
+
});
|
|
28
|
+
for (const g of interesting) {
|
|
29
|
+
// 只处理"被技能拦住"的 Goal: 其它原因等人的不要乱动
|
|
30
|
+
const blockedBySkill = g.continuation?.skillReadiness?.ok === false;
|
|
31
|
+
if (!blockedBySkill)
|
|
32
|
+
continue;
|
|
33
|
+
// 若是被人工标记 needs_human 且与技能无关, 跳过
|
|
34
|
+
out.rechecked++;
|
|
35
|
+
const res = await ensureGoalSkillsReady(g, { home });
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
// 技能恢复了 → 重新冻结快照 + 回 active (Supervisor 下一轮继续)
|
|
38
|
+
const approved = await approveSkillUpgrade(g.goalId, { home });
|
|
39
|
+
if (approved.ok) {
|
|
40
|
+
out.resumed.push(g.goalId);
|
|
41
|
+
console.log(`[skill-link] ${g.goalId} 技能已恢复 (${opts.action || 'unknown'}${opts.name ? `:${opts.name}` : ''}) → 重新冻结快照并回到 active`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
out.stillBlocked.push(g.goalId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
await blockGoalOnSkills(g.goalId, res);
|
|
49
|
+
out.stillBlocked.push(g.goalId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** 技能被禁用/隔离/漂移时, 把依赖它的活跃 Goal 标清事实 (不打断当前 Run) */
|
|
55
|
+
export async function markDependentsOfSkill(name, opts = { reason: '技能不可用' }) {
|
|
56
|
+
const sm = new SkillsManager({ home: opts.home, cwd: process.cwd() });
|
|
57
|
+
const health = await sm.health({ home: opts.home }).catch(() => null);
|
|
58
|
+
const goals = await listGoals({ limit: 200 });
|
|
59
|
+
const affected = [];
|
|
60
|
+
for (const g of goals) {
|
|
61
|
+
const { required } = parseSkillSpecs(g.requiredSkills || []);
|
|
62
|
+
if (!required.includes(name))
|
|
63
|
+
continue;
|
|
64
|
+
if (['completed', 'failed', 'abandoned'].includes(g.status))
|
|
65
|
+
continue;
|
|
66
|
+
affected.push(g.goalId);
|
|
67
|
+
await recordDegradation({ kind: 'observational', op: 'skill-link', message: `Goal ${g.goalId} 依赖的技能 ${name} 状态: ${JSON.stringify(health?.byStatus || {})} (${opts.reason}) — 下一次 Run 前会重新门禁` }).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
return affected;
|
|
70
|
+
}
|
|
@@ -19,6 +19,10 @@ import * as os from 'os';
|
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
import * as fsp from 'fs/promises';
|
|
21
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';
|
|
22
26
|
import { parseSkillFile, defaultSkillPaths } from './skill-loader.js';
|
|
23
27
|
import { getUserSkillsDir } from './skill-writer.js';
|
|
24
28
|
import { collectSkillBundle, parseSkillBundle, parseSkillRef, fetchSkillBundle, installSkillBundle, } from './skill-share.js';
|
|
@@ -319,6 +323,8 @@ export class SkillsManager {
|
|
|
319
323
|
if (rec.issues.length)
|
|
320
324
|
return { ok: false, reason: `技能不合格, 不能启用: ${rec.issues.join('; ')}` };
|
|
321
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);
|
|
322
328
|
return { ok: true, skill: skill || undefined };
|
|
323
329
|
}
|
|
324
330
|
async disable(name, opts = {}) {
|
|
@@ -326,6 +332,14 @@ export class SkillsManager {
|
|
|
326
332
|
if (!rec)
|
|
327
333
|
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
328
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 { /* 联动失败不影响禁用结果 */ }
|
|
329
343
|
return { ok: true, skill: skill || undefined };
|
|
330
344
|
}
|
|
331
345
|
/** 人工批准 (信任等级 verified); 2-G.3 的 import 事务会要求它才算"可用" */
|
|
@@ -349,6 +363,14 @@ export class SkillsManager {
|
|
|
349
363
|
};
|
|
350
364
|
await writeRegistry(reg, opts.home ?? this.home);
|
|
351
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 { /* 联动失败不影响隔离结果 */ }
|
|
352
374
|
return { ok: true, skill: (await this.inspect(name, opts)) || undefined };
|
|
353
375
|
}
|
|
354
376
|
/** 结构校验: 重算问题清单并把状态落成 invalid (或从 invalid 恢复) */
|
|
@@ -372,6 +394,14 @@ export class SkillsManager {
|
|
|
372
394
|
* 事务化 (临时目录 + 原子移动 + hash 校验) 属 2-G.3。
|
|
373
395
|
*/
|
|
374
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 = {}) {
|
|
375
405
|
const cid = parseSkillRef(ref);
|
|
376
406
|
if (!cid)
|
|
377
407
|
return { ok: false, error: `无法识别的技能引用 (要 bolloon://skill/<cid> / ipfs://<cid> / 裸 CID): ${ref.slice(0, 60)}` };
|
|
@@ -395,6 +425,14 @@ export class SkillsManager {
|
|
|
395
425
|
}
|
|
396
426
|
/** 从已解析的技能包装入 (本地文件/已取到的包) */
|
|
397
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 = {}) {
|
|
398
436
|
const parsed = parseSkillBundle(bundleJson);
|
|
399
437
|
if (!parsed.ok || !parsed.bundle)
|
|
400
438
|
return { ok: false, error: parsed.error || '包格式非法' };
|
|
@@ -412,6 +450,203 @@ export class SkillsManager {
|
|
|
412
450
|
}
|
|
413
451
|
return { ok: true, name: parsed.bundle.name, skill: (await this.inspect(parsed.bundle.name, { home: h })) || undefined };
|
|
414
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
|
+
}
|
|
415
650
|
// ── 视图 ─────────────────────────────────────────────────────────────────
|
|
416
651
|
/** 给 CLI / Web / agent 的同一份列表 (字段一致, 顺序一致: name 升序) */
|
|
417
652
|
async view(opts = {}) {
|
|
@@ -433,3 +668,50 @@ export function resetSkillsManagerForTest() {
|
|
|
433
668
|
export function formatSkillLine(s) {
|
|
434
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} 个问题` : ''}`;
|
|
435
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
|
+
}
|