@dreanzy/dsh-skill-pack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-skill-pack
3
+ name: '@dreanzy/dsh-skill-pack'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Skill manager">
2
+ <rect x="2" y="3.5" width="20" height="17" rx="4" fill="none" stroke="#2f6fed" stroke-width="1.6" />
3
+ <path d="M7 8.5h6M7 12h4.5" stroke="#2f6fed" stroke-width="1.6" stroke-linecap="round" />
4
+ <circle cx="16.6" cy="8.5" r="2.1" fill="#2f6fed" />
5
+ <circle cx="15.2" cy="15.5" r="2.1" fill="none" stroke="#2f6fed" stroke-width="1.5" />
6
+ </svg>
@@ -0,0 +1,46 @@
1
+ import { type Decision } from './policy.js';
2
+ import type { PolicyState } from './store.js';
3
+ /**
4
+ * 一行的求值结论。
5
+ *
6
+ * `open` 为 `null` 表示这次结论不该被信任:清单不完整时遮蔽集会被当成空集,
7
+ * 此时说「关」就是假话。`decidedBy` / `decidedVia` 仍保留链自己的答案,面板可以
8
+ * 当诊断显示,但不能当生效状态。
9
+ */
10
+ export interface EffectiveRow {
11
+ readonly name: string;
12
+ readonly groupId: string | null;
13
+ /** 生效值;清单不完整时为 `null`。 */
14
+ readonly open: boolean | null;
15
+ /** 只按 default pack 求值的值。它与清单是否完整无关。 */
16
+ readonly openGlobal: boolean;
17
+ /** default pack 里这一项的项级记录;没有键为 `null`(跟随)。 */
18
+ readonly itemGlobal: boolean | null;
19
+ /** 工作区绑定的 pack 里这一项的项级记录;未绑定或没有键为 `null`。 */
20
+ readonly itemWorkspace: boolean | null;
21
+ /** 做出决定的 pack id;落到内置缺省时是 `default`。 */
22
+ readonly decidedBy: string;
23
+ readonly decidedVia: Decision['via'];
24
+ }
25
+ export interface EffectiveInput {
26
+ readonly policy: PolicyState;
27
+ /** 要算的名字,通常是清单里的全量名字。 */
28
+ readonly names: readonly string[];
29
+ readonly scope: 'global' | 'workspace';
30
+ /** 清单是否完整。不完整时 `open` 一律为 `null`,`hidden` 为空。 */
31
+ readonly complete: boolean;
32
+ }
33
+ export interface Effective {
34
+ readonly complete: boolean;
35
+ readonly rows: readonly EffectiveRow[];
36
+ /** 本轮到遮蔽的名字,已排序。清单不完整时为空:那一刻宁可不动。 */
37
+ readonly hidden: readonly string[];
38
+ isOpen(name: string): boolean;
39
+ }
40
+ /**
41
+ * 一次求值,一份答案。
42
+ *
43
+ * 求值链只在这里组装一次:装配路径要 `hidden`,面板要 `rows`,两个读者拿到的
44
+ * 必定是同一份结论。浏览器侧不再重算——那条链已经删掉了。
45
+ */
46
+ export declare function effective(input: EffectiveInput): Effective;
@@ -0,0 +1,39 @@
1
+ import { createResolver, defaultPackOf } from './policy.js';
2
+ /**
3
+ * 一次求值,一份答案。
4
+ *
5
+ * 求值链只在这里组装一次:装配路径要 `hidden`,面板要 `rows`,两个读者拿到的
6
+ * 必定是同一份结论。浏览器侧不再重算——那条链已经删掉了。
7
+ */
8
+ export function effective(input) {
9
+ const { policy, names, scope, complete } = input;
10
+ const defaultPack = defaultPackOf(policy.packs);
11
+ const groups = policy.groups;
12
+ const resolver = createResolver(scope === 'workspace'
13
+ ? { pack: policy.pack, packId: policy.packId, defaultPack, groups }
14
+ : { pack: null, defaultPack, groups });
15
+ const globalResolver = createResolver({ pack: null, defaultPack, groups });
16
+ const answers = new Map();
17
+ const rows = [];
18
+ for (const name of names) {
19
+ const decision = resolver.decide(name);
20
+ answers.set(name, decision);
21
+ rows.push({
22
+ name,
23
+ groupId: resolver.groupOf(name) ?? null,
24
+ open: complete ? decision.value : null,
25
+ openGlobal: globalResolver.decide(name).value,
26
+ itemGlobal: defaultPack.items[name] ?? null,
27
+ itemWorkspace: policy.pack === null ? null : (policy.pack.items[name] ?? null),
28
+ decidedBy: decision.packId,
29
+ decidedVia: decision.via,
30
+ });
31
+ }
32
+ const isOpen = (name) => {
33
+ const known = answers.get(name);
34
+ // 清单外的名字仍可问:链本身对任意名字都有答案。
35
+ return known === undefined ? resolver.decide(name).value : known.value;
36
+ };
37
+ const hidden = complete ? names.filter((name) => !isOpen(name)).sort() : [];
38
+ return { complete, rows, hidden, isOpen };
39
+ }
package/lib/hooks.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { type ScopeSnapshot } from './snapshot.js';
2
+ /** 宿主 ctx 的最小面:订阅、纳管、取服务、记日志。 */
3
+ export interface PluginContext {
4
+ on(event: string, listener: (payload: unknown) => unknown): () => void;
5
+ effect(callback: () => unknown): unknown;
6
+ get(name: string): unknown;
7
+ logger: {
8
+ info(message: string): void;
9
+ warn(message: string): void;
10
+ };
11
+ }
12
+ /**
13
+ * 装好监听器并返回重新落地的入口。
14
+ *
15
+ * 只订阅三个事件:`agent/created`、`agent/disposed`、`skills/change`。
16
+ * 不挂 `agent/pre-step` 或 `system-prompt/assemble`——`agent/created` 是 serial
17
+ * 派发,必然早于该 Agent 的首次装配,不需要接管装配瀑布。
18
+ *
19
+ * 策略不冻结:每次重新落地都重读两层文件,所以保存的效果落在下一个回合边界。
20
+ */
21
+ export interface HooksOptions {
22
+ /** 注入一份共享快照;缺省按本包的真实读取自建。测试用它绕开文件系统。 */
23
+ readonly snapshot?: ScopeSnapshot;
24
+ }
25
+ export declare function installHooks(ctx: PluginContext, options?: HooksOptions): {
26
+ reapply: () => Promise<void>;
27
+ bootstrap: () => Promise<void>;
28
+ stopAll: () => void;
29
+ stuck: () => readonly string[];
30
+ /** 与落地共用的那份求值缓存。路由要用同一份,否则面板与模型看到的会分叉。 */
31
+ snapshot: ScopeSnapshot;
32
+ };
package/lib/hooks.js ADDED
@@ -0,0 +1,403 @@
1
+ import {} from './inventory.js';
2
+ import { createReconciler } from './reconcile.js';
3
+ import { createScopeSnapshot, fingerprintOf } from './snapshot.js';
4
+ import { loadState } from './store.js';
5
+ function asAgentLike(value) {
6
+ if (typeof value !== 'object' || value === null)
7
+ return null;
8
+ const record = value;
9
+ const session = record['session'];
10
+ if (typeof session !== 'object' || session === null)
11
+ return null;
12
+ const sessionRecord = session;
13
+ if (typeof record['id'] !== 'string' || typeof sessionRecord['id'] !== 'string')
14
+ return null;
15
+ const header = sessionRecord['header'];
16
+ const cwd = typeof header === 'object' && header !== null ? header['cwd'] : undefined;
17
+ return {
18
+ id: record['id'],
19
+ ctx: record['ctx'],
20
+ session: {
21
+ id: sessionRecord['id'],
22
+ ...(typeof cwd === 'string' ? { header: { cwd } } : {}),
23
+ },
24
+ raw: value,
25
+ };
26
+ }
27
+ function cwdOf(agent) {
28
+ const cwd = agent.session.header?.cwd;
29
+ return typeof cwd === 'string' && cwd !== '' ? cwd : null;
30
+ }
31
+ /**
32
+ * 取该 Agent 作用域的 skills 实例。
33
+ *
34
+ * **必须由 `agent.ctx.get('skills')` 取**:`register()` 用 `scopeOf(this.ctx)`
35
+ * 决定落到哪一层,只有这个实例代表的才是该 Agent 的层。
36
+ */
37
+ function scopedSkills(agent) {
38
+ const get = agent.ctx?.get;
39
+ if (typeof get !== 'function')
40
+ return null;
41
+ try {
42
+ const service = get.call(agent.ctx, 'skills');
43
+ if (typeof service !== 'object' || service === null)
44
+ return null;
45
+ return service;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ async function prepareShadows(skills, rows, hidden, cwd, scope) {
52
+ const byName = new Map(rows.map((row) => [row.name, row]));
53
+ /**
54
+ * **先取全部定义,再统一注册。**
55
+ *
56
+ * `skills.register()` 会无条件让注册表的 revision +1 并清掉收集缓存,而
57
+ * `skills.get()` 的缓存正按 revision 做键。所以「取一个 → 注册一个」会让
58
+ * 每次 `get()` 都必然缓存未命中、触发一次完整枚举:一百来个隐藏项实测要
59
+ * 三秒以上,而这段代码跑在 `agent/created` 里、被宿主串行 await——用户看到的
60
+ * 就是「点新会话要等很久」。
61
+ *
62
+ * 分段之后只有第一次 `get()` 可能全量,其余命中缓存;注册本身是微秒级。
63
+ */
64
+ const prepared = [];
65
+ for (const name of hidden) {
66
+ const row = byName.get(name);
67
+ if (row === undefined)
68
+ continue;
69
+ let content = '';
70
+ try {
71
+ const definition = await skills.get(name, { cwd, scope });
72
+ if (definition !== undefined && typeof definition.content === 'string')
73
+ content = definition.content;
74
+ }
75
+ catch {
76
+ // 读不到正文只影响手动调用的内容,不影响遮蔽本身。
77
+ }
78
+ prepared.push({ row, content });
79
+ }
80
+ return prepared;
81
+ }
82
+ /**
83
+ * 同步注册一份遮蔽。
84
+ *
85
+ * 放进 reconcile 的 `build` 里调用:那一刻上一轮已经释放;这一半失败也退回无资源,
86
+ * 不会留下半份。
87
+ */
88
+ function registerShadows(ctx, skills, drafts) {
89
+ const disposers = [];
90
+ for (const { row, content } of drafts) {
91
+ try {
92
+ disposers.push(skills.register({
93
+ name: row.name,
94
+ description: row.description,
95
+ content,
96
+ source: row.source,
97
+ ...(row.path === undefined ? {} : { path: row.path }),
98
+ invocation: { modelInvocable: false, userInvocable: true },
99
+ }));
100
+ }
101
+ catch (error) {
102
+ ctx.logger.warn(`dsh-skill-pack: 注册遮蔽副本失败(${row.name}):${String(error)}`);
103
+ }
104
+ }
105
+ if (disposers.length === 0)
106
+ return null;
107
+ return () => {
108
+ for (const dispose of disposers) {
109
+ try {
110
+ dispose();
111
+ }
112
+ catch {
113
+ // 释放失败只影响内存,下一次落地会重建。
114
+ }
115
+ }
116
+ };
117
+ }
118
+ /**
119
+ * 复验:判据是 `invocation.modelInvocable !== false`,不是「名字还在不在」。
120
+ * 遮蔽只改 invocation 标记,名字永远在清单里。
121
+ */
122
+ async function verifyShadows(ctx, skills, hidden, cwd, scope) {
123
+ let stuck = [];
124
+ try {
125
+ const after = await skills.snapshot({ cwd, scope });
126
+ if (after.complete) {
127
+ const visible = new Set();
128
+ for (const row of after.skills) {
129
+ if (row.invocation?.modelInvocable !== false)
130
+ visible.add(row.name);
131
+ }
132
+ stuck = hidden.filter((name) => visible.has(name));
133
+ if (stuck.length > 0) {
134
+ ctx.logger.warn(`dsh-skill-pack: 以下 skill 关不掉(同层已被别的插件注册):${stuck.join(', ')}`);
135
+ }
136
+ }
137
+ }
138
+ catch {
139
+ // 复验失败不影响已落地的遮蔽,只是这条会话的「关不掉」名单未知。
140
+ }
141
+ return stuck;
142
+ }
143
+ export function installHooks(ctx, options) {
144
+ const states = new Map();
145
+ /**
146
+ * 每个 agent 一份装配记账:先释放上一轮、串行、失败退回无资源,三件事都在这里。
147
+ * key 用 agent id,一个 agent 只有一份遮蔽。
148
+ */
149
+ const shadows = createReconciler((message) => {
150
+ ctx.logger.warn(`dsh-skill-pack: ${message}`);
151
+ });
152
+ /**
153
+ * scope 对象 → agent 的索引。
154
+ *
155
+ * 快照的键是「cwd + scope 身份」,而读清单必须用该 agent 自己的 skills 实例
156
+ * (`register()` 用 `scopeOf(ctx)` 决定落到哪一层)。刷新时只拿得到 scope 对象,
157
+ * 所以在这里反查回 agent。
158
+ */
159
+ const agentByScope = new WeakMap();
160
+ const rootSkills = () => {
161
+ try {
162
+ const service = ctx.get('skills');
163
+ return typeof service === 'object' && service !== null ? service : null;
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ };
169
+ /**
170
+ * 读一份**未缓存**的清单。
171
+ *
172
+ * 刻意不复用 `inventory.ts` 的 `readInventory`:它的缓存键不含 scope 身份,会让
173
+ * 不同 preset 的会话读到彼此的清单。缓存统一由 `ScopeSnapshot` 负责。
174
+ */
175
+ const readFresh = async (cwd, scope) => {
176
+ const agent = typeof scope === 'object' && scope !== null ? agentByScope.get(scope) : undefined;
177
+ const skills = agent === undefined ? rootSkills() : scopedSkills(agent);
178
+ if (skills === null)
179
+ throw new Error('取不到 skills 服务,无法读取清单');
180
+ const request = {};
181
+ if (cwd !== null && cwd !== '')
182
+ request.cwd = cwd;
183
+ if (scope !== undefined)
184
+ request.scope = scope;
185
+ const result = await skills.snapshot(request);
186
+ return { rows: result.skills, complete: result.complete };
187
+ };
188
+ const snapshot = options?.snapshot ??
189
+ createScopeSnapshot({
190
+ loadPolicy: (cwd) => loadState(cwd),
191
+ readInventory: readFresh,
192
+ fingerprint: fingerprintOf,
193
+ });
194
+ /**
195
+ * 本插件正在落地时的计数。
196
+ *
197
+ * 遮蔽是 `skills.register()`,而它会触发 `skills/change`;本监听器又监听
198
+ * `skills/change`。没有这个守卫,一次落地会自我复制成注册风暴,表现为
199
+ * 新建会话卡顿数秒、且首条消息的 skill 目录整体消失(快照 revisions 抖动)。
200
+ * 用计数而非布尔:落地期间可能嵌套一次真正的外部变化。
201
+ */
202
+ let applying = 0;
203
+ /** 落地期间到达的清单变化。直接丢掉会让遮蔽停在上一个版本,所以记下来补跑。 */
204
+ let dirty = false;
205
+ async function guarded(operation) {
206
+ applying += 1;
207
+ try {
208
+ await operation();
209
+ }
210
+ finally {
211
+ applying -= 1;
212
+ if (applying === 0 && dirty) {
213
+ dirty = false;
214
+ void onChange().catch((error) => {
215
+ ctx.logger.warn(`dsh-skill-pack: 补跑清单变化失败:${String(error)}`);
216
+ });
217
+ }
218
+ }
219
+ }
220
+ const applyOne = async (state) => {
221
+ const cwd = cwdOf(state.agent);
222
+ if (cwd === null)
223
+ return;
224
+ const skills = scopedSkills(state.agent);
225
+ if (skills === null) {
226
+ ctx.logger.warn(`dsh-skill-pack: 取不到 agent 作用域的 skills 服务(agent=${state.agent.id})`);
227
+ return;
228
+ }
229
+ // 求值按 cwd + scope 共享:同一会话的多次落地(created、清单变更、保存、
230
+ // 插件重载)只在策略或清单真的变了之后才重新读盘。
231
+ // 清单始终是全量的(含官方技能):官方技能只是面板默认不展示,一旦用户把它们
232
+ // 放出来并关掉,「显示官方技能」开关再关回去,那条决定仍然有效。
233
+ //
234
+ // 绑定表的键是规范化后的 cwd;归一在 `loadState` 内部完成,用的是
235
+ // `policy.ts` 的 `normalizeCwd` 唯一实现。这里刻意保留原始 cwd 给清单读取:
236
+ // 宿主按原写法解析工程根,改写大小写会让工程 skill 找不到。
237
+ const view = await snapshot.refresh(cwd, state.agent.raw);
238
+ for (const warning of view.warnings)
239
+ ctx.logger.warn(`dsh-skill-pack: ${warning}`);
240
+ if (!view.complete) {
241
+ ctx.logger.warn(`dsh-skill-pack: skill 清单不完整,本轮不改变遮蔽(agent=${state.agent.id})`);
242
+ return;
243
+ }
244
+ const hidden = view.hidden;
245
+ // 计划没变就跳过:这条判断留在调用方,reconcile 只管落地。
246
+ const key = hidden.join(',');
247
+ if (key === state.key)
248
+ return;
249
+ // 读正文不碰注册表,可以在这里并发;注册必须排进 reconcile 的队列,
250
+ // 这样「先释放上一轮、再施加这一轮」才成立。
251
+ const drafts = await prepareShadows(skills, view.rows, hidden, cwd, state.agent.raw);
252
+ const landed = await shadows.apply(state.agent.id, () => registerShadows(ctx, skills, drafts));
253
+ if (!landed) {
254
+ // 上一轮已被释放、这一轮没有资源:不推进 key,下一个事件会重试。
255
+ state.stuck = [];
256
+ return;
257
+ }
258
+ state.key = key;
259
+ state.stuck =
260
+ drafts.length === 0 ? [] : await verifyShadows(ctx, skills, hidden, cwd, state.agent.raw);
261
+ };
262
+ const onCreated = async (payload) => {
263
+ // 这个事件的监听器抛错会让会话创建失败(仓库红线),所以整条路径吞掉降级。
264
+ try {
265
+ const agent = asAgentLike(payload?.agent);
266
+ if (agent === null) {
267
+ ctx.logger.warn('dsh-skill-pack: agent/created 负载形状不符,跳过');
268
+ return;
269
+ }
270
+ if (cwdOf(agent) === null) {
271
+ ctx.logger.warn(`dsh-skill-pack: agent/created 缺少 cwd,跳过(agent=${agent.id})`);
272
+ return;
273
+ }
274
+ // 宿主可能对同一个 agent id 再发一次 created(会话恢复、重连)。
275
+ // 覆盖前必须释放旧遮蔽,否则注册表里留下没人能释放的第二份副本。
276
+ shadows.release(agent.id);
277
+ const state = { agent, key: '', stuck: [] };
278
+ states.set(agent.id, state);
279
+ if (typeof agent.raw === 'object' && agent.raw !== null)
280
+ agentByScope.set(agent.raw, agent);
281
+ await guarded(async () => {
282
+ await applyOne(state);
283
+ });
284
+ }
285
+ catch (error) {
286
+ ctx.logger.warn(`dsh-skill-pack: agent/created 落地失败,本会话按空策略运行:${String(error)}`);
287
+ }
288
+ };
289
+ const onDisposed = (payload) => {
290
+ const agent = asAgentLike(payload?.agent);
291
+ if (agent === null)
292
+ return;
293
+ const state = states.get(agent.id);
294
+ if (state === undefined)
295
+ return;
296
+ // 释放并忘记这个主体的资源;还排在队列里的那一轮会自己作废。
297
+ shadows.release(agent.id);
298
+ states.delete(agent.id);
299
+ // 这一格已经没有消费者;不清理会让缓存按开过的会话数单调增长。
300
+ // 同目录还有活会话时会被一并打掉,代价是它们下一轮重读一次。
301
+ const cwd = cwdOf(agent);
302
+ if (cwd !== null)
303
+ snapshot.invalidate(cwd);
304
+ };
305
+ async function onChange() {
306
+ if (applying > 0) {
307
+ // 自己注册遮蔽触发的 skills/change 会走这里;外部变化也走这里,不能丢。
308
+ dirty = true;
309
+ return;
310
+ }
311
+ // 清单变化不改三份策略文件,指纹挡不住;不主动打掉就会一直读到旧清单。
312
+ snapshot.invalidate();
313
+ await guarded(async () => {
314
+ // 并发而不是串行:不同 agent 落在各自的 scope 上,互不依赖;同键的刷新由
315
+ // `ScopeSnapshot` 单飞合并,所以并发不会把一次变更放大成 N 次读盘。
316
+ await Promise.all([...states.values()].map(async (state) => {
317
+ try {
318
+ await applyOne(state);
319
+ }
320
+ catch (error) {
321
+ ctx.logger.warn(`dsh-skill-pack: 清单变化后重新落地失败(agent=${state.agent.id}):${String(error)}`);
322
+ }
323
+ }));
324
+ });
325
+ }
326
+ const offCreated = ctx.on('agent/created', onCreated);
327
+ // 事件名必须带斜杠,与宿主 emit 的字符串逐字一致;写成点号不会报错,也永远不会被调用。
328
+ const offSkills = ctx.on('skills/change', onChange);
329
+ const offDisposed = ctx.on('agent/disposed', onDisposed);
330
+ /**
331
+ * 策略保存不触碰 skill 注册表,因此不会发出 `skills/change`。
332
+ * 保存成功后由路由显式调用这里,否则同一会话里改策略要等新会话才生效。
333
+ */
334
+ const reapply = async () => {
335
+ // 策略刚被写盘:onChange 里的显式失效不靠 mtime 精度。同毫秒内的两次写在
336
+ // Windows 上可能得到同一个时间戳,靠指纹会漏掉第二次。
337
+ await onChange();
338
+ };
339
+ const stopAll = () => {
340
+ for (const off of [offCreated, offSkills, offDisposed]) {
341
+ try {
342
+ off();
343
+ }
344
+ catch {
345
+ // 退订失败不影响后续释放。
346
+ }
347
+ }
348
+ for (const id of states.keys())
349
+ shadows.release(id);
350
+ states.clear();
351
+ snapshot.invalidate();
352
+ };
353
+ ctx.effect(() => stopAll);
354
+ /**
355
+ * 插件重载后补一次落地。
356
+ *
357
+ * `states` 是进程内表,插件一重载就清空;而那些**已经存在**的会话不会再收到
358
+ * `agent/created`,于是一直以为自己没有被遮蔽——直到用户重开会话,表现就是
359
+ * 「策略改了,手上这个会话还是能看到被关掉的 skill」。
360
+ * 这里主动枚举一次 live Agent,把表补回来。
361
+ */
362
+ const bootstrap = async () => {
363
+ let list = [];
364
+ try {
365
+ const agents = ctx.get('agents');
366
+ const raw = typeof agents?.list === 'function' ? agents.list.call(agents) : [];
367
+ list = Array.isArray(raw) ? raw : [];
368
+ }
369
+ catch {
370
+ return;
371
+ }
372
+ for (const raw of list) {
373
+ const agent = asAgentLike({ agent: raw });
374
+ if (agent === null || states.has(agent.id) || cwdOf(agent) === null)
375
+ continue;
376
+ const state = { agent, key: '', stuck: [] };
377
+ states.set(agent.id, state);
378
+ if (typeof agent.raw === 'object' && agent.raw !== null)
379
+ agentByScope.set(agent.raw, agent);
380
+ try {
381
+ await guarded(async () => {
382
+ await applyOne(state);
383
+ });
384
+ }
385
+ catch (error) {
386
+ ctx.logger.warn(`dsh-skill-pack: 补落地失败(agent=${agent.id}):${String(error)}`);
387
+ }
388
+ }
389
+ };
390
+ return {
391
+ reapply,
392
+ bootstrap,
393
+ stopAll,
394
+ snapshot,
395
+ stuck: () => {
396
+ const out = new Set();
397
+ for (const state of states.values())
398
+ for (const name of state.stuck)
399
+ out.add(name);
400
+ return [...out].sort();
401
+ },
402
+ };
403
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** 插件名,同时是 bundle patch 的 entry id。 */
2
+ export declare const name = "dsh-skill-pack";
3
+ /**
4
+ * 硬依赖只有 `skills`:清单读取与遮蔽注册都取不到替代。
5
+ *
6
+ * 刻意不注入 `tools`(不管 MCP)、不注入 `agents`(用自己维护的会话表就够了)、
7
+ * 不注入 `connection`(无连接服务的 profile 也必须能装配;路由阶段再可选嵌套注入)。
8
+ */
9
+ export declare const inject: readonly ["skills"];
10
+ export declare function apply(ctx: unknown): void;
package/lib/index.js ADDED
@@ -0,0 +1,109 @@
1
+ import { installHooks } from './hooks.js';
2
+ import { handleApply, handleGlobal, handleWorkspace, } from './routes.js';
3
+ /** 插件名,同时是 bundle patch 的 entry id。 */
4
+ export const name = 'dsh-skill-pack';
5
+ /**
6
+ * 硬依赖只有 `skills`:清单读取与遮蔽注册都取不到替代。
7
+ *
8
+ * 刻意不注入 `tools`(不管 MCP)、不注入 `agents`(用自己维护的会话表就够了)、
9
+ * 不注入 `connection`(无连接服务的 profile 也必须能装配;路由阶段再可选嵌套注入)。
10
+ */
11
+ export const inject = ['skills'];
12
+ /** 路由路径。宿主按完整 pathname 匹配,`/api` 前缀不能省。 */
13
+ const ROUTES = {
14
+ global: '/api/skill-pack/global',
15
+ workspace: '/api/skill-pack/workspace',
16
+ apply: '/api/skill-pack/apply',
17
+ };
18
+ function toResponse(envelope) {
19
+ // HTTP 状态恒为 200:前端只认信封里的 `code`,非 2xx 会让它丢掉 body。
20
+ return Response.json(envelope, { headers: { 'cache-control': 'no-store' } });
21
+ }
22
+ function wrap(handler) {
23
+ return async (request) => {
24
+ let body = null;
25
+ try {
26
+ const parsed = await request.json();
27
+ body = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
28
+ ? parsed
29
+ : null;
30
+ }
31
+ catch {
32
+ // 空体合法:全局读取不需要任何入参。
33
+ }
34
+ try {
35
+ return toResponse(await handler(body));
36
+ }
37
+ catch (error) {
38
+ return toResponse({
39
+ ok: false,
40
+ error: { code: 'internal', message: `路由处理失败:${String(error)}` },
41
+ });
42
+ }
43
+ };
44
+ }
45
+ function readFetchFace(ctx) {
46
+ const connection = ctx.get('connection');
47
+ if (typeof connection !== 'object' || connection === null)
48
+ return null;
49
+ const fetchFace = connection['fetch'];
50
+ if (typeof fetchFace !== 'object' || fetchFace === null)
51
+ return null;
52
+ const register = fetchFace['register'];
53
+ if (typeof register !== 'function')
54
+ return null;
55
+ return { register: (spec) => register.call(fetchFace, spec) };
56
+ }
57
+ export function apply(ctx) {
58
+ const context = ctx;
59
+ const hooks = installHooks(context);
60
+ // 重载后补一次落地:已有会话不会再收到 agent/created,不补的话它们会一直
61
+ // 保持「没有遮蔽」,直到用户重开会话。失败只降级,不影响插件加载。
62
+ void hooks.bootstrap().catch((error) => {
63
+ context.logger.warn(`${name}: 重载后补落地失败:${String(error)}`);
64
+ });
65
+ context.inject(['connection'], () => {
66
+ const face = readFetchFace(context);
67
+ if (face === null) {
68
+ context.logger.warn(`${name}: connection 服务在册但没有 fetch.register,路由未注册(策略仍生效)`);
69
+ return;
70
+ }
71
+ const routeCtx = { get: (key) => context.get(key), logger: context.logger };
72
+ // 与 hooks 共用同一份快照:面板上的隐藏集必须就是会话里生效的那一份。
73
+ const deps = { snapshot: hooks.snapshot, reapply: hooks.reapply, stuck: hooks.stuck };
74
+ const disposers = [];
75
+ const register = (path, handler) => {
76
+ try {
77
+ disposers.push(face.register({ path, methods: ['POST'], requestBody: 'buffered', fetch: wrap(handler) }));
78
+ }
79
+ catch (error) {
80
+ context.logger.warn(`${name}: 注册路由失败(${path}):${String(error)}`);
81
+ }
82
+ };
83
+ register(ROUTES.global, (body) => {
84
+ // 面板会带当前会话:全局面板的行集合因此与会话一致,而不是「恰好排第一的那个」。
85
+ const sessionId = body !== null && typeof body['sessionId'] === 'string' && body['sessionId'] !== ''
86
+ ? body['sessionId']
87
+ : undefined;
88
+ return handleGlobal(routeCtx, deps, sessionId);
89
+ });
90
+ register(ROUTES.workspace, (body) => handleWorkspace(routeCtx, body, deps));
91
+ register(ROUTES.apply, (body) => handleApply(routeCtx, body, deps));
92
+ context.effect(() => () => {
93
+ for (const dispose of disposers) {
94
+ try {
95
+ const result = dispose();
96
+ if (result !== null && typeof result === 'object' && 'catch' in result) {
97
+ void result.catch(() => {
98
+ // 释放失败只影响这次卸载,没有可回滚的状态。
99
+ });
100
+ }
101
+ }
102
+ catch {
103
+ // 同上。
104
+ }
105
+ }
106
+ });
107
+ });
108
+ context.logger.info(`${name}: applied`);
109
+ }