@bolloon/bolloon-agent 0.4.24 → 0.4.25
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 +391 -0
- package/dist/agents/goal-store.js +451 -0
- package/dist/agents/pi-harness.js +263 -0
- package/dist/agents/pi-sdk.js +568 -126
- package/dist/agents/run-store.js +772 -0
- package/dist/agents/runner-resolver.js +225 -0
- package/dist/agents/skills-manager.js +435 -0
- package/dist/agents/supervisor-host.js +249 -0
- package/dist/cron/tick-lock.js +1 -1
- package/dist/index.js +428 -22
- 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/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 +386 -0
- package/package.json +2 -2
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runner-resolver.ts — 独立宿主的**分阶段**执行器解析 (2026-09-16, 批次 2-C.2)
|
|
3
|
+
*
|
|
4
|
+
* 起因: 独立宿主"没有 tick 日志、没有建 Run"这种故障无法定位 —— 20 秒超时只能说"超时",
|
|
5
|
+
* 说不出卡在哪。这一层把解析拆成有序阶段, 每阶段记 开始/结束/耗时/失败分类/超时原因:
|
|
6
|
+
*
|
|
7
|
+
* resolve_goal → resolve_agent → load_identity → load_session → load_skills
|
|
8
|
+
* → init_llm → create_session → prepare_resume → ready
|
|
9
|
+
*
|
|
10
|
+
* 任一必需阶段失败 → `{ ok:false, kind:'unresolved', reason:'<阶段>: <原因>', stages }`
|
|
11
|
+
* → Supervisor **只诊断**: 不建 Run、不改 Goal 状态、不伪造失败; 原因进 wakeReport/宿主状态。
|
|
12
|
+
*
|
|
13
|
+
* 顺带修一个真根因: 独立宿主此前**没有初始化 LLM 层**, 于是 `PiAgentSession.prompt()` 判定
|
|
14
|
+
* `minimaxAvailable=false` 走 fallback 提前返回 —— 既不调模型也不建 Run, 上层只看到"执行完成但没有 Run"。
|
|
15
|
+
* 现在 `init_llm` 是必需阶段, LLM 不可用直接 unresolved (而不是偷偷退化成 fallback)。
|
|
16
|
+
*/
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as fsp from 'fs/promises';
|
|
20
|
+
import { readGoal } from './goal-store.js';
|
|
21
|
+
import { buildContinuationPlan } from './run-store.js';
|
|
22
|
+
export const RESOLVE_STAGES = [
|
|
23
|
+
'resolve_goal',
|
|
24
|
+
'resolve_agent',
|
|
25
|
+
'load_identity',
|
|
26
|
+
'load_session',
|
|
27
|
+
'load_skills',
|
|
28
|
+
'init_llm',
|
|
29
|
+
'create_session',
|
|
30
|
+
'prepare_resume',
|
|
31
|
+
'ready',
|
|
32
|
+
];
|
|
33
|
+
function classify(err, stage) {
|
|
34
|
+
const m = String(err?.message || err || '').toLowerCase();
|
|
35
|
+
if (m.includes('超时') || m.includes('timeout'))
|
|
36
|
+
return 'timeout';
|
|
37
|
+
if (stage === 'init_llm')
|
|
38
|
+
return 'config';
|
|
39
|
+
if (m.includes('eacces') || m.includes('enoent') || m.includes('eperm'))
|
|
40
|
+
return 'io';
|
|
41
|
+
return 'unknown';
|
|
42
|
+
}
|
|
43
|
+
/** 读本机 LLM 配置摘要 (只看结构与有无, 不打印任何密钥) */
|
|
44
|
+
export async function probeLlmConfig(home = os.homedir()) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(await fsp.readFile(path.join(home, '.bolloon', 'llm-config.json'), 'utf8'));
|
|
47
|
+
const active = raw.activeProvider || raw.provider;
|
|
48
|
+
const providers = raw.providers || {};
|
|
49
|
+
const p = providers[active] || {};
|
|
50
|
+
const needsKey = p.requiresApiKey !== false;
|
|
51
|
+
const hasKey = !!(p.apiKey || p.api_key || process.env[`${String(active).toUpperCase()}_API_KEY`]);
|
|
52
|
+
if (!active)
|
|
53
|
+
return { ok: false, reason: 'llm-config.json 没有 activeProvider' };
|
|
54
|
+
if (needsKey && !hasKey)
|
|
55
|
+
return { ok: false, provider: active, model: p.model, hasKey: false, reason: `provider ${active} 需要 apiKey 但没配` };
|
|
56
|
+
return { ok: true, provider: active, model: p.model, hasKey };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ok: false, reason: `llm-config.json 不可读: ${String(err?.message || err).slice(0, 100)}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function stage(stages, name, required, timeoutMs, fn, log, noteOf) {
|
|
63
|
+
const t0 = Date.now();
|
|
64
|
+
log?.(`[resolver] ${name} 开始`);
|
|
65
|
+
try {
|
|
66
|
+
const value = await Promise.race([
|
|
67
|
+
fn(),
|
|
68
|
+
new Promise((_r, rej) => {
|
|
69
|
+
const t = setTimeout(() => rej(new Error(`${name} 超时 (${timeoutMs}ms)`)), timeoutMs);
|
|
70
|
+
t.unref?.();
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
const report = { stage: name, ok: true, ms: Date.now() - t0, required, note: noteOf?.(value) };
|
|
74
|
+
stages.push(report);
|
|
75
|
+
log?.(`[resolver] ${name} 完成 ${report.ms}ms`);
|
|
76
|
+
return { ok: true, value, report };
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const report = {
|
|
80
|
+
stage: name, ok: false, ms: Date.now() - t0, required,
|
|
81
|
+
error: String(err?.message || err).slice(0, 200),
|
|
82
|
+
errorClass: classify(err, name),
|
|
83
|
+
};
|
|
84
|
+
stages.push(report);
|
|
85
|
+
log?.(`[resolver] ${name} 失败 ${report.ms}ms: ${report.error}`);
|
|
86
|
+
return { ok: false, report };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** 阶段报告一行摘要 (CLI / 日志 / wakeReport 用) */
|
|
90
|
+
export function formatStages(stages) {
|
|
91
|
+
return stages.map((s) => `${s.stage}${s.ok ? '✓' : '✗'}${s.ms}ms`).join(' → ');
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 分阶段解析"谁来执行这个 Goal"。
|
|
95
|
+
* 与 web 宿主同构: 都返回一个 runner; 差别只在"用什么上下文建 session"。
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveGoalRunner(req, opts = {}) {
|
|
98
|
+
const home = opts.home ?? os.homedir();
|
|
99
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
100
|
+
const log = opts.log;
|
|
101
|
+
const stageTimeout = opts.stageTimeoutMs ?? 30_000;
|
|
102
|
+
const createTimeout = opts.createTimeoutMs ?? (Number(process.env.BOLLOON_SUPERVISE_CREATE_TIMEOUT_MS) || 20_000);
|
|
103
|
+
const stages = [];
|
|
104
|
+
const fail = (report, reason) => ({
|
|
105
|
+
ok: false, kind: 'none', reason, stages, failedStage: report.stage,
|
|
106
|
+
});
|
|
107
|
+
if (opts.allow === false) {
|
|
108
|
+
return fail({ stage: 'resolve_goal', ok: false, ms: 0, required: true, error: '本地执行被显式关闭' }, 'resolve_goal: 本地执行被显式关闭 (BOLLOON_SUPERVISE_AGENT=0) → 只诊断');
|
|
109
|
+
}
|
|
110
|
+
// 1. resolve_goal —— 目标是否可执行
|
|
111
|
+
const g = req.goal;
|
|
112
|
+
const goalCheck = await stage(stages, 'resolve_goal', true, stageTimeout, async () => {
|
|
113
|
+
if (!g?.goalId)
|
|
114
|
+
throw new Error('没有 goalId');
|
|
115
|
+
const fresh = await readGoal(g.goalId);
|
|
116
|
+
if (!fresh)
|
|
117
|
+
throw new Error(`Goal 不存在: ${g.goalId}`);
|
|
118
|
+
if (fresh.status === 'paused' || fresh.status === 'needs_human')
|
|
119
|
+
throw new Error(`Goal 状态 ${fresh.status} 不允许自动执行`);
|
|
120
|
+
return fresh;
|
|
121
|
+
}, log);
|
|
122
|
+
if (!goalCheck.ok)
|
|
123
|
+
return fail(goalCheck.report, `resolve_goal: ${goalCheck.report.error} → 只诊断, Goal 状态未改动`);
|
|
124
|
+
const goal = goalCheck.value;
|
|
125
|
+
// 2. resolve_agent —— 用哪个 agent 身份执行
|
|
126
|
+
const agentCheck = await stage(stages, 'resolve_agent', true, stageTimeout, async () => {
|
|
127
|
+
if (!goal.channelId && !req.prevRunId)
|
|
128
|
+
throw new Error('Goal 没有 channelId (无法确定 agent 上下文)');
|
|
129
|
+
return { channelId: goal.channelId || '', agentId: goal.agentId || '' };
|
|
130
|
+
}, log);
|
|
131
|
+
if (!agentCheck.ok)
|
|
132
|
+
return fail(agentCheck.report, `resolve_agent: ${agentCheck.report.error} → 只诊断 (不建 Run)`);
|
|
133
|
+
const { channelId, agentId } = agentCheck.value;
|
|
134
|
+
// 3. load_identity —— 身份可读性 (缺身份不致命, 但要留痕)
|
|
135
|
+
await stage(stages, 'load_identity', false, stageTimeout, async () => {
|
|
136
|
+
const files = ['keypair.json', 'agent-registry.json'].map((f) => path.join(home, '.bolloon', f));
|
|
137
|
+
const found = [];
|
|
138
|
+
for (const f of files)
|
|
139
|
+
if (await fsp.stat(f).then(() => true).catch(() => false))
|
|
140
|
+
found.push(path.basename(f));
|
|
141
|
+
return found;
|
|
142
|
+
}, log, (found) => (found.length ? `身份文件: ${found.join(', ')}` : '无身份文件 (用系统临时身份)'));
|
|
143
|
+
// 4. load_session —— 会话存储可读性
|
|
144
|
+
await stage(stages, 'load_session', false, stageTimeout, async () => {
|
|
145
|
+
const dir = path.join(home, '.bolloon', 'sessions');
|
|
146
|
+
return fsp.stat(dir).then((st) => st.isDirectory()).catch(() => false);
|
|
147
|
+
}, log, (ok) => (ok ? `会话目录可读: ${path.join(home, '.bolloon', 'sessions')}` : '会话目录不存在 (将新建)'));
|
|
148
|
+
// 5. load_skills —— 技能视图可读性 (2-G.2 起会变成必需阶段)
|
|
149
|
+
await stage(stages, 'load_skills', false, stageTimeout, async () => {
|
|
150
|
+
const { getSkillsManager } = await import('./skills-manager.js');
|
|
151
|
+
const list = await getSkillsManager({ home, cwd }).view();
|
|
152
|
+
return { total: list.length, enabled: list.filter((s) => s.status === 'enabled').length, invalid: list.filter((s) => s.status === 'invalid').length };
|
|
153
|
+
}, log, (r) => `技能视图: ${r.total} 个 (enabled ${r.enabled}, invalid ${r.invalid})`);
|
|
154
|
+
// 6. init_llm —— **必需**: LLM 可用 (否则 agent 会退化成 fallback, 既不调模型也不建 Run)
|
|
155
|
+
const llmCheck = await stage(stages, 'init_llm', true, stageTimeout, async () => {
|
|
156
|
+
const probe = opts.probeLlm ? await opts.probeLlm() : await probeLlmConfig(home);
|
|
157
|
+
if (!probe.ok)
|
|
158
|
+
throw new Error(probe.reason || 'LLM 不可用');
|
|
159
|
+
const { initMinimax } = await import('../constraints/index.js');
|
|
160
|
+
initMinimax();
|
|
161
|
+
return probe;
|
|
162
|
+
}, log, (probe) => `provider=${probe.provider} model=${probe.model ?? '(默认)'} key=${probe.hasKey ? '有' : '免'}`);
|
|
163
|
+
if (!llmCheck.ok) {
|
|
164
|
+
return fail(llmCheck.report, `init_llm: ${llmCheck.report.error} → 只诊断 (不发起会退化成 fallback 的执行)`);
|
|
165
|
+
}
|
|
166
|
+
// 7. create_session —— 真 agent session
|
|
167
|
+
let agent;
|
|
168
|
+
const sessCheck = await stage(stages, 'create_session', true, createTimeout, async () => {
|
|
169
|
+
if (opts.createAgent) {
|
|
170
|
+
agent = await opts.createAgent(channelId, goal.goalId);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
const { createAgentSession } = await import('./pi-sdk.js');
|
|
174
|
+
agent = await createAgentSession({ cwd, peerId: `supervise:${channelId || agentId}`, channelId: channelId || undefined }, true);
|
|
175
|
+
return !!agent;
|
|
176
|
+
}, log);
|
|
177
|
+
if (!sessCheck.ok || !agent)
|
|
178
|
+
return fail(sessCheck.report, `create_session: ${sessCheck.report.error} → 只诊断 (不建 Run)`);
|
|
179
|
+
// 8. prepare_resume —— 恢复计划 (只读; 真正 prepareResume 由 runner 在运行时做, 保证状态迁移与执行同一时刻)
|
|
180
|
+
let resumeNote = '新 Run (无历史)';
|
|
181
|
+
if (req.prevRunId) {
|
|
182
|
+
const planCheck = await stage(stages, 'prepare_resume', false, stageTimeout, async () => {
|
|
183
|
+
const plan = await buildContinuationPlan(req.prevRunId);
|
|
184
|
+
if (!plan)
|
|
185
|
+
throw new Error(`读不到上一条 Run: ${req.prevRunId}`);
|
|
186
|
+
resumeNote = `已完成 ${plan.completedSteps.length} 步, 非幂等守卫 ${plan.replayGuards.length} 条`;
|
|
187
|
+
return plan;
|
|
188
|
+
}, log);
|
|
189
|
+
if (!planCheck.ok)
|
|
190
|
+
resumeNote = `恢复计划读取失败: ${planCheck.report.error}`;
|
|
191
|
+
}
|
|
192
|
+
stages.push({ stage: 'ready', ok: true, ms: 0, required: true, note: `runner 就绪 (channel=${channelId || '-'}, ${resumeNote})` });
|
|
193
|
+
const runner = async (r) => {
|
|
194
|
+
if (r.kind === 'resume' && r.prevRunId && typeof agent.resumeRun === 'function') {
|
|
195
|
+
const res = await agent.resumeRun(r.prevRunId);
|
|
196
|
+
return { runId: r.prevRunId, status: res?.ok ? 'done' : 'failed', error: res?.ok ? undefined : res?.reason };
|
|
197
|
+
}
|
|
198
|
+
agent.setGoalId?.(goal.goalId);
|
|
199
|
+
agent.setContinuationGuards?.(r.guards || []);
|
|
200
|
+
const reply = await agent.prompt(r.instruction);
|
|
201
|
+
const runId = agent.getLastRunId?.() || agent.getRunId?.() || '';
|
|
202
|
+
// 没有 Run = 这次执行没有事实记录: 如实报告, 不让上层把"没跑"当"跑完"
|
|
203
|
+
if (!runId) {
|
|
204
|
+
return { status: 'failed', error: `执行没有产生 Run 记录 (agent 可能走了 fallback/未初始化路径): ${String(reply || '').slice(0, 120)}` };
|
|
205
|
+
}
|
|
206
|
+
return { runId, status: 'done', reply: typeof reply === 'string' ? reply.slice(0, 500) : undefined };
|
|
207
|
+
};
|
|
208
|
+
return { ok: true, kind: 'standalone', runner, stages };
|
|
209
|
+
}
|
|
210
|
+
/** 已启动的独立宿主把最近一次解析报告写给上层 (wakeReport / API / CLI 用) */
|
|
211
|
+
export function latestFailedStage(res) {
|
|
212
|
+
return { stage: res.failedStage, reason: res.ok ? undefined : res.reason };
|
|
213
|
+
}
|
|
214
|
+
/** 供 CLI/诊断: 一次解析的完整人类可读报告 */
|
|
215
|
+
export function describeResolution(res) {
|
|
216
|
+
const failed = res.stages.find((s) => !s.ok);
|
|
217
|
+
const head = res.ok ? '✅ 可执行' : `⛔ 不可执行 (卡在 ${res.failedStage})`;
|
|
218
|
+
const lines = [`${head}${res.reason ? ` — ${res.reason}` : ''}`, `阶段: ${formatStages(res.stages)}`];
|
|
219
|
+
for (const s of res.stages)
|
|
220
|
+
if (s.note)
|
|
221
|
+
lines.push(` · ${s.stage}: ${s.note}`);
|
|
222
|
+
if (failed?.error)
|
|
223
|
+
lines.push(` ✗ 失败分类=${failed.errorClass} 原因=${failed.error}`);
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
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
|
+
import { parseSkillFile, defaultSkillPaths } from './skill-loader.js';
|
|
23
|
+
import { getUserSkillsDir } from './skill-writer.js';
|
|
24
|
+
import { collectSkillBundle, parseSkillBundle, parseSkillRef, fetchSkillBundle, installSkillBundle, } from './skill-share.js';
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
export function registryPath(home = os.homedir()) {
|
|
27
|
+
return path.join(home, '.bolloon', 'skills-registry.json');
|
|
28
|
+
}
|
|
29
|
+
async function readRegistry(home) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(await fsp.readFile(registryPath(home), 'utf8'));
|
|
32
|
+
if (raw && typeof raw.skills === 'object' && raw.skills)
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
catch { /* 缺文件/坏文件 → 空 registry (不阻挡发现) */ }
|
|
36
|
+
return { schema: 'bolloon-skills-registry/1', skills: {}, updatedAt: new Date(0).toISOString() };
|
|
37
|
+
}
|
|
38
|
+
async function writeRegistry(reg, home) {
|
|
39
|
+
const p = registryPath(home);
|
|
40
|
+
await fsp.mkdir(path.dirname(p), { recursive: true });
|
|
41
|
+
reg.updatedAt = new Date().toISOString();
|
|
42
|
+
const tmp = `${p}.tmp`;
|
|
43
|
+
await fsp.writeFile(tmp, JSON.stringify(reg, null, 2), 'utf8');
|
|
44
|
+
await fsp.rename(tmp, p); // 原子替换
|
|
45
|
+
}
|
|
46
|
+
/** 技能目录内容摘要: 排序后逐文件 sha256 → 再摘要一次 (目录整体指纹) */
|
|
47
|
+
export async function hashSkillDir(dir) {
|
|
48
|
+
const issues = [];
|
|
49
|
+
const files = [];
|
|
50
|
+
let bytes = 0;
|
|
51
|
+
const walk = async (d, prefix) => {
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = await fsp.readdir(d, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
issues.push(`目录不可读: ${String(err?.message || err).slice(0, 80)}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
61
|
+
if (e.name.startsWith('.'))
|
|
62
|
+
continue;
|
|
63
|
+
const abs = path.join(d, e.name);
|
|
64
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
65
|
+
if (e.isSymbolicLink()) {
|
|
66
|
+
issues.push(`符号链接被跳过 (避免越界): ${rel}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (e.isDirectory()) {
|
|
70
|
+
await walk(abs, rel);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!e.isFile())
|
|
74
|
+
continue;
|
|
75
|
+
try {
|
|
76
|
+
const buf = await fsp.readFile(abs);
|
|
77
|
+
bytes += buf.length;
|
|
78
|
+
files.push({ rel, content: buf });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
issues.push(`文件不可读: ${rel} (${String(err?.message || err).slice(0, 60)})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
await walk(dir, '');
|
|
86
|
+
const h = crypto.createHash('sha256');
|
|
87
|
+
for (const f of files) {
|
|
88
|
+
h.update(f.rel);
|
|
89
|
+
h.update('\0');
|
|
90
|
+
h.update(crypto.createHash('sha256').update(f.content).digest());
|
|
91
|
+
}
|
|
92
|
+
return { hash: h.digest('hex').slice(0, 32), fileCount: files.length, bytes, issues };
|
|
93
|
+
}
|
|
94
|
+
/** 单条记录的结构校验 (纯函数, 可单测) */
|
|
95
|
+
export function validateSkillRecord(input) {
|
|
96
|
+
const issues = [...(input.issues || [])];
|
|
97
|
+
if (!input.name || !/^[a-z0-9_-]{1,64}$/i.test(input.name))
|
|
98
|
+
issues.push('技能名非法 (只允许字母数字下划线连字符, ≤64)');
|
|
99
|
+
if (!input.description || input.description.trim().length < 4)
|
|
100
|
+
issues.push('缺少 description (SKILL.md frontmatter)');
|
|
101
|
+
if (!input.body || input.body.trim().length < 20)
|
|
102
|
+
issues.push('正文内容过少 (可能不是有效的 SKILL.md)');
|
|
103
|
+
if (input.bytes > 2 * 1024 * 1024)
|
|
104
|
+
issues.push(`技能体积过大 (${Math.round(input.bytes / 1024)}KB > 2MB)`);
|
|
105
|
+
const fmStatus = String(input.frontmatter?.status ?? 'active');
|
|
106
|
+
if (!['active', 'archived', 'draft'].includes(fmStatus))
|
|
107
|
+
issues.push(`frontmatter.status 非法: ${fmStatus}`);
|
|
108
|
+
return issues;
|
|
109
|
+
}
|
|
110
|
+
export class SkillsManager {
|
|
111
|
+
home;
|
|
112
|
+
cwd;
|
|
113
|
+
cache = null;
|
|
114
|
+
/** 同名技能出现在哪些目录 (discover 时记录; 同名会被覆盖成一条记录, 重复必须单独留痕) */
|
|
115
|
+
dirsByName = new Map();
|
|
116
|
+
constructor(opts = {}) {
|
|
117
|
+
this.home = opts.home ?? os.homedir();
|
|
118
|
+
this.cwd = opts.cwd ?? process.cwd();
|
|
119
|
+
}
|
|
120
|
+
/** 技能搜索路径 (去重, 顺序 = 优先级从低到高) */
|
|
121
|
+
skillDirs() {
|
|
122
|
+
const out = [
|
|
123
|
+
{ dir: path.join(this.cwd, '.bolloon', 'skills'), source: 'project' },
|
|
124
|
+
{ dir: getUserSkillsDir(this.home), source: 'user' },
|
|
125
|
+
];
|
|
126
|
+
for (const p of defaultSkillPaths(this.home, this.cwd)) {
|
|
127
|
+
if (!out.some((x) => x.dir === p))
|
|
128
|
+
out.push({ dir: p, source: 'user' });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/** 扫描所有技能目录 + registry → 统一视图 (同名: 后者覆盖前者, 与 loader 语义一致) */
|
|
133
|
+
async discover(opts = {}) {
|
|
134
|
+
const home = opts.home ?? this.home;
|
|
135
|
+
const reg = await readRegistry(home);
|
|
136
|
+
const byName = new Map();
|
|
137
|
+
const dirsByName = new Map();
|
|
138
|
+
for (const { dir, source } of this.skillDirs()) {
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
148
|
+
continue;
|
|
149
|
+
const skillDir = path.join(dir, e.name);
|
|
150
|
+
const skillFile = path.join(skillDir, 'SKILL.md');
|
|
151
|
+
const meta = await parseSkillFile(skillFile).catch(() => null);
|
|
152
|
+
const counted = await hashSkillDir(skillDir);
|
|
153
|
+
if (!meta) {
|
|
154
|
+
// 目录在但不是有效技能: 也进视图 (状态 invalid), 不许静默消失
|
|
155
|
+
const rec = this.buildRecord({
|
|
156
|
+
name: e.name, description: '', version: '0.0.0', frontmatter: {}, body: '',
|
|
157
|
+
dir: skillDir, skillFile, source, hash: counted.hash, fileCount: counted.fileCount,
|
|
158
|
+
bytes: counted.bytes, issues: [`SKILL.md 缺失或无法解析`, ...counted.issues], reg,
|
|
159
|
+
});
|
|
160
|
+
byName.set(rec.name, rec);
|
|
161
|
+
dirsByName.set(rec.name, [...(dirsByName.get(rec.name) || []), skillDir]);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const rec = this.buildRecord({
|
|
165
|
+
name: meta.name || e.name, description: meta.description, version: String(meta.frontmatter?.version ?? '0.0.0'),
|
|
166
|
+
frontmatter: meta.frontmatter, body: meta.body, dir: skillDir, skillFile, source,
|
|
167
|
+
hash: counted.hash, fileCount: counted.fileCount, bytes: counted.bytes, issues: counted.issues, reg, tier: meta.tier, triggers: meta.triggers,
|
|
168
|
+
});
|
|
169
|
+
byName.set(rec.name, rec);
|
|
170
|
+
dirsByName.set(rec.name, [...(dirsByName.get(rec.name) || []), skillDir]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const records = Array.from(byName.values());
|
|
174
|
+
this.cache = records;
|
|
175
|
+
this.dirsByName = dirsByName;
|
|
176
|
+
if (opts.writeRegistry !== false) {
|
|
177
|
+
// 首次发现把缺记录补进 registry (内容 hash 作为基线), 以后才能检出漂移
|
|
178
|
+
let changed = false;
|
|
179
|
+
for (const r of records) {
|
|
180
|
+
if (!reg.skills[r.name]) {
|
|
181
|
+
reg.skills[r.name] = {
|
|
182
|
+
skillId: r.skillId, name: r.name, version: r.version, contentHash: r.contentHash,
|
|
183
|
+
source: r.source, sourceRef: r.dir, status: r.status, trust: r.trust,
|
|
184
|
+
installedAt: r.installedAt, updatedAt: r.updatedAt,
|
|
185
|
+
};
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (changed)
|
|
190
|
+
await writeRegistry(reg, home).catch(() => { });
|
|
191
|
+
}
|
|
192
|
+
return records;
|
|
193
|
+
}
|
|
194
|
+
buildRecord(input) {
|
|
195
|
+
const prior = input.reg.skills[input.name];
|
|
196
|
+
const issues = validateSkillRecord({
|
|
197
|
+
name: input.name, description: input.description, body: input.body,
|
|
198
|
+
frontmatter: input.frontmatter, bytes: input.bytes, issues: input.issues,
|
|
199
|
+
});
|
|
200
|
+
const fmStatus = String(input.frontmatter?.status ?? 'active');
|
|
201
|
+
// 状态优先级: 结构坏 → invalid; registry 显式停用/隔离/归档 → 尊重; 否则按 frontmatter/来源推导
|
|
202
|
+
let status;
|
|
203
|
+
if (issues.length)
|
|
204
|
+
status = 'invalid';
|
|
205
|
+
else if (prior?.status === 'disabled' || prior?.status === 'quarantined' || prior?.status === 'archived')
|
|
206
|
+
status = prior.status;
|
|
207
|
+
else if (fmStatus === 'archived')
|
|
208
|
+
status = 'archived';
|
|
209
|
+
else if (fmStatus === 'draft')
|
|
210
|
+
status = 'discovered';
|
|
211
|
+
else
|
|
212
|
+
status = prior?.status === 'installed' ? 'installed' : 'enabled';
|
|
213
|
+
const source = prior?.source
|
|
214
|
+
|| (input.source === 'project' ? 'project' : 'user');
|
|
215
|
+
const now = new Date().toISOString();
|
|
216
|
+
return {
|
|
217
|
+
skillId: prior?.skillId || input.name,
|
|
218
|
+
name: input.name,
|
|
219
|
+
description: input.description,
|
|
220
|
+
version: input.version,
|
|
221
|
+
contentHash: input.hash,
|
|
222
|
+
source,
|
|
223
|
+
sourceRef: prior?.sourceRef || input.dir,
|
|
224
|
+
status,
|
|
225
|
+
trust: prior?.trust || 'unverified',
|
|
226
|
+
compatibility: input.frontmatter?.compatibility ? String(input.frontmatter.compatibility) : undefined,
|
|
227
|
+
tier: input.tier || 'utility',
|
|
228
|
+
triggers: input.triggers || [],
|
|
229
|
+
skillFile: input.skillFile,
|
|
230
|
+
dir: input.dir,
|
|
231
|
+
fileCount: input.fileCount,
|
|
232
|
+
bytes: input.bytes,
|
|
233
|
+
installedAt: prior?.installedAt || now,
|
|
234
|
+
updatedAt: now,
|
|
235
|
+
issues,
|
|
236
|
+
registryHash: prior?.contentHash,
|
|
237
|
+
approvedBy: prior?.approvedBy,
|
|
238
|
+
approvedAt: prior?.approvedAt,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** 单技能详情 (含正文长度与漂移判定) */
|
|
242
|
+
async inspect(name, opts = {}) {
|
|
243
|
+
const all = this.cache && !opts.home ? this.cache : await this.discover(opts);
|
|
244
|
+
return all.find((s) => s.name === name) || null;
|
|
245
|
+
}
|
|
246
|
+
/** 解析一组技能名 → 记录 (缺哪个说清); 2-G.2 的 readiness gate 就用这个 */
|
|
247
|
+
async resolve(names, opts = {}) {
|
|
248
|
+
const all = await this.discover(opts);
|
|
249
|
+
const resolved = [];
|
|
250
|
+
const missing = [];
|
|
251
|
+
const notEnabled = [];
|
|
252
|
+
for (const n of names) {
|
|
253
|
+
const hit = all.find((s) => s.name === n);
|
|
254
|
+
if (!hit) {
|
|
255
|
+
missing.push(n);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (hit.status !== 'enabled' && hit.status !== 'installed')
|
|
259
|
+
notEnabled.push(n);
|
|
260
|
+
resolved.push(hit);
|
|
261
|
+
}
|
|
262
|
+
return { ok: missing.length === 0 && notEnabled.length === 0, resolved, missing, notEnabled };
|
|
263
|
+
}
|
|
264
|
+
/** 版本固定的技能快照 (Goal 级固定版本用; resolvedAt 记录解析时刻) */
|
|
265
|
+
async snapshot(names, opts = {}) {
|
|
266
|
+
const r = await this.resolve(names, opts);
|
|
267
|
+
const resolvedAt = new Date().toISOString();
|
|
268
|
+
return {
|
|
269
|
+
ok: r.missing.length === 0,
|
|
270
|
+
missing: r.missing,
|
|
271
|
+
entries: r.resolved.map((s) => ({ name: s.name, version: s.version, contentHash: s.contentHash, source: s.source, resolvedAt })),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/** 健康检查: 状态/来源分布 + 漂移 + 不合格 + 重复 + registry 缺盘 */
|
|
275
|
+
async health(opts = {}) {
|
|
276
|
+
const home = opts.home ?? this.home;
|
|
277
|
+
const all = await this.discover(opts);
|
|
278
|
+
const reg = await readRegistry(home);
|
|
279
|
+
const byStatus = {};
|
|
280
|
+
const bySource = {};
|
|
281
|
+
const drifted = [];
|
|
282
|
+
const invalid = [];
|
|
283
|
+
const duplicates = [];
|
|
284
|
+
for (const s of all) {
|
|
285
|
+
byStatus[s.status] = (byStatus[s.status] || 0) + 1;
|
|
286
|
+
bySource[s.source] = (bySource[s.source] || 0) + 1;
|
|
287
|
+
if (s.issues.length)
|
|
288
|
+
invalid.push({ name: s.name, issues: s.issues });
|
|
289
|
+
const prior = reg.skills[s.name];
|
|
290
|
+
if (prior?.contentHash && prior.contentHash !== s.contentHash) {
|
|
291
|
+
drifted.push({ name: s.name, expected: prior.contentHash, actual: s.contentHash });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const [name, dirs] of this.dirsByName) {
|
|
295
|
+
const uniq = Array.from(new Set(dirs));
|
|
296
|
+
if (uniq.length > 1)
|
|
297
|
+
duplicates.push({ name, dirs: uniq });
|
|
298
|
+
}
|
|
299
|
+
const missing = Object.keys(reg.skills).filter((n) => !all.some((s) => s.name === n));
|
|
300
|
+
return { total: all.length, byStatus, bySource, drifted, invalid, duplicates, missing };
|
|
301
|
+
}
|
|
302
|
+
// ── 管理动作 (2-G.1: 只改状态与账, 不改执行行为) ──────────────────────────
|
|
303
|
+
async patchRegistry(name, patch, home) {
|
|
304
|
+
const h = home ?? this.home;
|
|
305
|
+
const rec = await this.inspect(name, { home: h });
|
|
306
|
+
if (!rec)
|
|
307
|
+
return null;
|
|
308
|
+
const reg = await readRegistry(h);
|
|
309
|
+
reg.skills[name] = { ...(reg.skills[name] || {}), ...patch, name };
|
|
310
|
+
await writeRegistry(reg, h);
|
|
311
|
+
this.cache = null;
|
|
312
|
+
const after = await this.inspect(name, { home: h });
|
|
313
|
+
return after;
|
|
314
|
+
}
|
|
315
|
+
async enable(name, opts = {}) {
|
|
316
|
+
const rec = await this.inspect(name, opts);
|
|
317
|
+
if (!rec)
|
|
318
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
319
|
+
if (rec.issues.length)
|
|
320
|
+
return { ok: false, reason: `技能不合格, 不能启用: ${rec.issues.join('; ')}` };
|
|
321
|
+
const skill = await this.patchRegistry(name, { status: 'enabled' }, opts.home);
|
|
322
|
+
return { ok: true, skill: skill || undefined };
|
|
323
|
+
}
|
|
324
|
+
async disable(name, opts = {}) {
|
|
325
|
+
const rec = await this.inspect(name, opts);
|
|
326
|
+
if (!rec)
|
|
327
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
328
|
+
const skill = await this.patchRegistry(name, { status: 'disabled' }, opts.home);
|
|
329
|
+
return { ok: true, skill: skill || undefined };
|
|
330
|
+
}
|
|
331
|
+
/** 人工批准 (信任等级 verified); 2-G.3 的 import 事务会要求它才算"可用" */
|
|
332
|
+
async approve(name, by = 'human', opts = {}) {
|
|
333
|
+
const rec = await this.inspect(name, opts);
|
|
334
|
+
if (!rec)
|
|
335
|
+
return { ok: false, reason: `没有这个技能: ${name}` };
|
|
336
|
+
const skill = await this.patchRegistry(name, { trust: 'verified', approvedBy: by, approvedAt: new Date().toISOString() }, opts.home);
|
|
337
|
+
return { ok: true, skill: skill || undefined };
|
|
338
|
+
}
|
|
339
|
+
/** 隔离 (损坏/不可信来源); 被隔离的技能不允许长期 Goal 自动使用 */
|
|
340
|
+
async quarantine(name, reason, opts = {}) {
|
|
341
|
+
const rec = await this.inspect(name, opts);
|
|
342
|
+
if (!rec)
|
|
343
|
+
return { ok: false };
|
|
344
|
+
const reg = await readRegistry(opts.home ?? this.home);
|
|
345
|
+
reg.skills[name] = {
|
|
346
|
+
...(reg.skills[name] || {}), name,
|
|
347
|
+
status: 'quarantined', trust: 'quarantined',
|
|
348
|
+
quarantineReason: reason.slice(0, 200), quarantinedAt: new Date().toISOString(),
|
|
349
|
+
};
|
|
350
|
+
await writeRegistry(reg, opts.home ?? this.home);
|
|
351
|
+
this.cache = null;
|
|
352
|
+
return { ok: true, skill: (await this.inspect(name, opts)) || undefined };
|
|
353
|
+
}
|
|
354
|
+
/** 结构校验: 重算问题清单并把状态落成 invalid (或从 invalid 恢复) */
|
|
355
|
+
async validate(name, opts = {}) {
|
|
356
|
+
const rec = await this.inspect(name, opts);
|
|
357
|
+
if (!rec)
|
|
358
|
+
return { ok: false, issues: [`没有这个技能: ${name}`] };
|
|
359
|
+
const skill = await this.patchRegistry(name, { status: rec.issues.length ? 'invalid' : (rec.status === 'invalid' ? 'enabled' : rec.status) }, opts.home);
|
|
360
|
+
return { ok: rec.issues.length === 0, issues: rec.issues, skill: skill || undefined };
|
|
361
|
+
}
|
|
362
|
+
/** 导出技能包 (复用 skill-share 的打包, 不重复实现) */
|
|
363
|
+
async export(name, opts = {}) {
|
|
364
|
+
const rec = await this.inspect(name, opts);
|
|
365
|
+
if (!rec)
|
|
366
|
+
return { ok: false, error: `没有这个技能: ${name}` };
|
|
367
|
+
return collectSkillBundle(rec.dir, { name: rec.name });
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 导入技能 (对话/链接/CID 三种写法都支持)。
|
|
371
|
+
* **2-G.1 只做统一入口 + 记账**: 下载 → 安装仍走现有 installSkillBundle (已有路径穿越校验 / 版本门 / 备份),
|
|
372
|
+
* 事务化 (临时目录 + 原子移动 + hash 校验) 属 2-G.3。
|
|
373
|
+
*/
|
|
374
|
+
async import(ref, opts = {}) {
|
|
375
|
+
const cid = parseSkillRef(ref);
|
|
376
|
+
if (!cid)
|
|
377
|
+
return { ok: false, error: `无法识别的技能引用 (要 bolloon://skill/<cid> / ipfs://<cid> / 裸 CID): ${ref.slice(0, 60)}` };
|
|
378
|
+
const fetched = await fetchSkillBundle(cid);
|
|
379
|
+
if (!fetched.ok || !fetched.bundle)
|
|
380
|
+
return { ok: false, error: fetched.error || '取包失败' };
|
|
381
|
+
const inst = await installSkillBundle(fetched.bundle, { home: opts.home ?? this.home, cwd: opts.cwd ?? this.cwd, force: opts.force, scope: opts.scope });
|
|
382
|
+
if (!inst.ok)
|
|
383
|
+
return { ok: false, error: inst.error };
|
|
384
|
+
const h = opts.home ?? this.home;
|
|
385
|
+
const name = fetched.bundle.name;
|
|
386
|
+
this.cache = null;
|
|
387
|
+
const after = await this.inspect(name, { home: h });
|
|
388
|
+
if (after) {
|
|
389
|
+
await this.patchRegistry(name, {
|
|
390
|
+
status: 'installed', source: opts.source || 'shared', sourceRef: cid, trust: 'unverified',
|
|
391
|
+
contentHash: after.contentHash, version: after.version,
|
|
392
|
+
}, h);
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, name, version: fetched.bundle.version, skill: (await this.inspect(name, { home: h })) || undefined };
|
|
395
|
+
}
|
|
396
|
+
/** 从已解析的技能包装入 (本地文件/已取到的包) */
|
|
397
|
+
async install(bundleJson, opts = {}) {
|
|
398
|
+
const parsed = parseSkillBundle(bundleJson);
|
|
399
|
+
if (!parsed.ok || !parsed.bundle)
|
|
400
|
+
return { ok: false, error: parsed.error || '包格式非法' };
|
|
401
|
+
const inst = await installSkillBundle(parsed.bundle, { home: opts.home ?? this.home, cwd: opts.cwd ?? this.cwd, force: opts.force });
|
|
402
|
+
if (!inst.ok)
|
|
403
|
+
return { ok: false, error: inst.error };
|
|
404
|
+
const h = opts.home ?? this.home;
|
|
405
|
+
this.cache = null;
|
|
406
|
+
const after = await this.inspect(parsed.bundle.name, { home: h });
|
|
407
|
+
if (after) {
|
|
408
|
+
await this.patchRegistry(parsed.bundle.name, {
|
|
409
|
+
status: 'installed', source: opts.source || 'imported', sourceRef: opts.sourceRef || 'local-bundle',
|
|
410
|
+
trust: 'unverified', contentHash: after.contentHash, version: after.version,
|
|
411
|
+
}, h);
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, name: parsed.bundle.name, skill: (await this.inspect(parsed.bundle.name, { home: h })) || undefined };
|
|
414
|
+
}
|
|
415
|
+
// ── 视图 ─────────────────────────────────────────────────────────────────
|
|
416
|
+
/** 给 CLI / Web / agent 的同一份列表 (字段一致, 顺序一致: name 升序) */
|
|
417
|
+
async view(opts = {}) {
|
|
418
|
+
const all = await this.discover(opts);
|
|
419
|
+
return [...all].sort((a, b) => a.name.localeCompare(b.name));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
let singleton = null;
|
|
423
|
+
/** 进程内单例 (CLI / Web / Supervisor 共用同一个视图实现) */
|
|
424
|
+
export function getSkillsManager(opts = {}) {
|
|
425
|
+
if (!singleton)
|
|
426
|
+
singleton = new SkillsManager(opts);
|
|
427
|
+
return singleton;
|
|
428
|
+
}
|
|
429
|
+
export function resetSkillsManagerForTest() {
|
|
430
|
+
singleton = null;
|
|
431
|
+
}
|
|
432
|
+
/** 一行摘要 (CLI / 日志用) */
|
|
433
|
+
export function formatSkillLine(s) {
|
|
434
|
+
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
|
+
}
|