@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.
- package/README.en.md +75 -0
- package/README.md +91 -0
- package/client/client.js +2024 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/lib/effective.d.ts +46 -0
- package/lib/effective.js +39 -0
- package/lib/hooks.d.ts +32 -0
- package/lib/hooks.js +403 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +109 -0
- package/lib/inventory.d.ts +54 -0
- package/lib/inventory.js +34 -0
- package/lib/policy.d.ts +85 -0
- package/lib/policy.js +386 -0
- package/lib/reconcile.d.ts +18 -0
- package/lib/reconcile.js +60 -0
- package/lib/routes.d.ts +102 -0
- package/lib/routes.js +511 -0
- package/lib/snapshot.d.ts +55 -0
- package/lib/snapshot.js +135 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.js +434 -0
- package/lib/types.d.ts +120 -0
- package/lib/types.js +13 -0
- package/locale/en.json +47 -0
- package/locale/zh.json +47 -0
- package/package.json +79 -0
package/lib/routes.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ScopeSnapshot } from './snapshot.js';
|
|
2
|
+
import { type Bindings, type Command, type GroupRegistry, type UiState } from './types.js';
|
|
3
|
+
/** 路由层需要的最小 ctx 面。 */
|
|
4
|
+
export interface RouteContext {
|
|
5
|
+
get(name: string): unknown;
|
|
6
|
+
logger: {
|
|
7
|
+
warn(message: string): void;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export type ErrorCode = 'bad-request' | 'unknown-session' | 'internal';
|
|
11
|
+
/**
|
|
12
|
+
* 路由层依赖。
|
|
13
|
+
*
|
|
14
|
+
* `snapshot` 与 hooks 共用同一份:面板看到的清单与模型看到的遮蔽必须由同一次求值
|
|
15
|
+
* 产生,否则会出现「面板显示关了、会话里还开着」这种无法复现的偏差。
|
|
16
|
+
*/
|
|
17
|
+
export interface RouteDeps {
|
|
18
|
+
readonly snapshot: ScopeSnapshot;
|
|
19
|
+
readonly reapply: () => Promise<void>;
|
|
20
|
+
readonly stuck: () => readonly string[];
|
|
21
|
+
}
|
|
22
|
+
export type Envelope<T> = {
|
|
23
|
+
readonly ok: true;
|
|
24
|
+
readonly value: T;
|
|
25
|
+
} | {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly error: {
|
|
28
|
+
readonly code: ErrorCode;
|
|
29
|
+
readonly message: string;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/** 面板用的一行 skill。求值结果由服务端算好,前端只负责渲染。 */
|
|
33
|
+
export interface SkillRow {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly description: string;
|
|
36
|
+
readonly source: string;
|
|
37
|
+
readonly provider: string;
|
|
38
|
+
/** 官方 `@deepseek-ai` 技能:默认不在面板出现。 */
|
|
39
|
+
readonly official: boolean;
|
|
40
|
+
readonly groupId: string | null;
|
|
41
|
+
/** 该工作区绑定的 pack 求值出的最终状态;清单不完整时为 `null`(未生效)。 */
|
|
42
|
+
readonly open: boolean | null;
|
|
43
|
+
/** 只按 default pack 求值的状态。全局面板用它渲染开关。 */
|
|
44
|
+
readonly openGlobal: boolean;
|
|
45
|
+
/** 行内是否带项级覆盖(该 pack 有键)。 */
|
|
46
|
+
readonly itemGlobal: boolean | null;
|
|
47
|
+
readonly itemWorkspace: boolean | null;
|
|
48
|
+
/** 生效值来自哪个 pack;取内置缺省时是 `default`。 */
|
|
49
|
+
readonly decidedBy: string;
|
|
50
|
+
/** 生效值来自 pack 的哪一级;两层都没记录时是 `builtin`。 */
|
|
51
|
+
readonly decidedVia: 'item' | 'group' | 'builtin';
|
|
52
|
+
/** 来源短标签,给人看:`.agents`、`.dsh`、`官方`、`插件名`、`运行时`。 */
|
|
53
|
+
readonly origin: string;
|
|
54
|
+
}
|
|
55
|
+
/** 一个 pack 的面板投影。 */
|
|
56
|
+
export interface PackView {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly items: Readonly<Record<string, boolean>>;
|
|
60
|
+
readonly groups: Readonly<Record<string, boolean>>;
|
|
61
|
+
/** `default` 是内置 pack:不可删除、不可改名。 */
|
|
62
|
+
readonly builtin: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface GlobalView {
|
|
65
|
+
/** 全部 pack,`default` 排第一。 */
|
|
66
|
+
readonly packs: readonly PackView[];
|
|
67
|
+
readonly ui: UiState;
|
|
68
|
+
readonly groups: GroupRegistry;
|
|
69
|
+
/** 绑定表:键是规范化后的 cwd。 */
|
|
70
|
+
readonly bindings: Bindings;
|
|
71
|
+
readonly skills: readonly SkillRow[];
|
|
72
|
+
readonly complete: boolean;
|
|
73
|
+
readonly warnings: readonly string[];
|
|
74
|
+
/** 已注册遮蔽但复验仍可见的名字。全局面板也要能提示「关不掉」。 */
|
|
75
|
+
readonly stuck: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
export interface WorkspaceView extends GlobalView {
|
|
78
|
+
readonly cwd: string;
|
|
79
|
+
/** 该 cwd 的原始绑定 id;未绑定为 `null`。可能悬空。 */
|
|
80
|
+
readonly binding: string | null;
|
|
81
|
+
/** 生效的 pack id;未绑定或悬空时为 `default`。 */
|
|
82
|
+
readonly packId: string;
|
|
83
|
+
readonly packName: string;
|
|
84
|
+
readonly live: boolean;
|
|
85
|
+
readonly hidden: readonly string[];
|
|
86
|
+
/** 已注册遮蔽副本但复验仍可见的名字。 */
|
|
87
|
+
readonly stuck: readonly string[];
|
|
88
|
+
}
|
|
89
|
+
/** `POST api/skill-pack/global`:全部 pack + 组注册表 + ui + 清单。 */
|
|
90
|
+
export declare function handleGlobal(ctx: RouteContext, deps: RouteDeps, sessionId?: string): Promise<Envelope<GlobalView>>;
|
|
91
|
+
/** `POST api/skill-pack/workspace`:`{ sessionId }`。 */
|
|
92
|
+
export declare function handleWorkspace(ctx: RouteContext, body: Record<string, unknown> | null, deps: RouteDeps): Promise<Envelope<WorkspaceView>>;
|
|
93
|
+
/** 校验入站意图。宁可拒绝整批,也不要半懂地写一半。 */
|
|
94
|
+
export declare function parseCommands(raw: unknown): {
|
|
95
|
+
ok: true;
|
|
96
|
+
commands: Command[];
|
|
97
|
+
} | {
|
|
98
|
+
ok: false;
|
|
99
|
+
message: string;
|
|
100
|
+
};
|
|
101
|
+
/** `POST api/skill-pack/apply`:`{ sessionId?, commands }`。 */
|
|
102
|
+
export declare function handleApply(ctx: RouteContext, body: Record<string, unknown> | null, deps: RouteDeps): Promise<Envelope<WorkspaceView | GlobalView>>;
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { defaultPackOf } from './policy.js';
|
|
2
|
+
import { effective } from './effective.js';
|
|
3
|
+
import { isOfficialSkill } from './inventory.js';
|
|
4
|
+
import { applyCommands } from './store.js';
|
|
5
|
+
import { DEFAULT_PACK_ID, DEFAULT_PACK_NAME, } from './types.js';
|
|
6
|
+
function asRecord(value) {
|
|
7
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
8
|
+
return null;
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function readString(source, key) {
|
|
12
|
+
const value = source?.[key];
|
|
13
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 把 `sessionId` 解析成工作目录与它的 live Agent。
|
|
17
|
+
*
|
|
18
|
+
* `agent` 必须是 `agents.get()` 返回的**对象本体**:`dsh-scope` 按对象身份查
|
|
19
|
+
* 作用域链,形状相同的替身查不到 agent 层,清单会静默退化成 global 层。
|
|
20
|
+
* 它同时是 `snapshot({ scope })` 的唯一合法入参。
|
|
21
|
+
*
|
|
22
|
+
* 先问 live Agent,再退回 `sessionQuery` 的只读观察(后者没有 scope,只能给出 cwd)。
|
|
23
|
+
*
|
|
24
|
+
* `observeSession` 的观察体**必须在 finally 里释放**:宿主为每次观察保留订阅,
|
|
25
|
+
* 漏一次就是一条长期泄漏;它只暴露 `Symbol.dispose`,没有 `.dispose()`。
|
|
26
|
+
*/
|
|
27
|
+
async function resolveSession(ctx, sessionId) {
|
|
28
|
+
const agents = asRecord(ctx.get('agents'));
|
|
29
|
+
const get = agents?.['get'];
|
|
30
|
+
if (typeof get === 'function') {
|
|
31
|
+
try {
|
|
32
|
+
const agent = get.call(agents, sessionId);
|
|
33
|
+
const record = asRecord(agent);
|
|
34
|
+
const cwd = readString(asRecord(asRecord(record?.['session'])?.['header']), 'cwd');
|
|
35
|
+
if (cwd !== null)
|
|
36
|
+
return { cwd, live: true, agent };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Agent 侧失败不致命,继续问 sessionQuery。
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const query = asRecord(ctx.get('sessionQuery'));
|
|
43
|
+
const observe = query?.['observeSession'];
|
|
44
|
+
if (typeof observe === 'function') {
|
|
45
|
+
let observation;
|
|
46
|
+
try {
|
|
47
|
+
observation = await observe.call(query, sessionId, { projectionMode: 'none' });
|
|
48
|
+
const cwd = readString(asRecord(asRecord(observation)?.['header']), 'cwd');
|
|
49
|
+
if (cwd !== null)
|
|
50
|
+
return { cwd, live: false, agent: undefined };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// 未知会话在这里 reject;交给调用方报错。
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
releaseObservation(ctx, observation, sessionId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
function releaseObservation(ctx, observation, sessionId) {
|
|
62
|
+
const record = asRecord(observation);
|
|
63
|
+
if (record === null)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
const dispose = record[Symbol.dispose];
|
|
67
|
+
if (typeof dispose === 'function')
|
|
68
|
+
dispose.call(observation);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
ctx.logger.warn(`dsh-skill-pack: 释放会话观察失败(sessionId=${sessionId}):${String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 来源短标签,给人看。
|
|
76
|
+
*
|
|
77
|
+
* 宿主只给 `provider` 与 `path`:`source` 由 provider 自己填(磁盘 provider 一律填
|
|
78
|
+
* `custom`),对用户没有信息量。按顺序判定:
|
|
79
|
+
* 1. `runtime`:注册表直接注入,没有磁盘出处;
|
|
80
|
+
* 2. 非 `filesystem` / `unknown` 的 provider:那就是某个插件的名字;
|
|
81
|
+
* 3. 路径含 `@deepseek-ai`:官方技能;
|
|
82
|
+
* 4. 其余把路径上卷到 `skills/` 一级,取注册根目录名——
|
|
83
|
+
* `~/.agents`、`<cwd>/.dsh`,或 `node_modules/<包名>` 里的包名。
|
|
84
|
+
*/
|
|
85
|
+
function originOf(row) {
|
|
86
|
+
if (row.provider === 'runtime')
|
|
87
|
+
return '运行时';
|
|
88
|
+
if (row.provider !== 'filesystem' && row.provider !== 'unknown')
|
|
89
|
+
return row.provider;
|
|
90
|
+
if (row.path === undefined)
|
|
91
|
+
return '';
|
|
92
|
+
const posix = row.path.replace(/\\/g, '/');
|
|
93
|
+
if (posix.includes('@deepseek-ai'))
|
|
94
|
+
return '官方';
|
|
95
|
+
const at = posix.lastIndexOf('/skills/');
|
|
96
|
+
const root = at >= 0 ? posix.slice(0, at) : posix.slice(0, posix.lastIndexOf('/'));
|
|
97
|
+
const segments = root.split('/').filter((segment) => segment !== '');
|
|
98
|
+
const nodeModules = segments.lastIndexOf('node_modules');
|
|
99
|
+
if (nodeModules >= 0 && nodeModules + 1 < segments.length) {
|
|
100
|
+
const pkg = segments[nodeModules + 1];
|
|
101
|
+
if (pkg !== undefined && pkg !== '' && pkg !== 'skills') {
|
|
102
|
+
// scoped 包占两段:`node_modules/@scope/pkg/skills/x` 的包名是 `@scope/pkg`。
|
|
103
|
+
const inner = segments[nodeModules + 2];
|
|
104
|
+
if (pkg.startsWith('@') && inner !== undefined && inner !== '')
|
|
105
|
+
return `${pkg}/${inner}`;
|
|
106
|
+
return pkg;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return segments[segments.length - 1] ?? '';
|
|
110
|
+
}
|
|
111
|
+
function packViews(book) {
|
|
112
|
+
const ids = Object.keys(book.packs).sort((a, b) => {
|
|
113
|
+
if (a === b)
|
|
114
|
+
return 0;
|
|
115
|
+
if (a === DEFAULT_PACK_ID)
|
|
116
|
+
return -1;
|
|
117
|
+
if (b === DEFAULT_PACK_ID)
|
|
118
|
+
return 1;
|
|
119
|
+
return a < b ? -1 : 1;
|
|
120
|
+
});
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
const pack = book.packs[id];
|
|
124
|
+
if (pack === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
out.push({
|
|
127
|
+
id,
|
|
128
|
+
name: id === DEFAULT_PACK_ID ? DEFAULT_PACK_NAME : pack.name,
|
|
129
|
+
items: pack.items,
|
|
130
|
+
groups: pack.groups,
|
|
131
|
+
builtin: id === DEFAULT_PACK_ID,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 造面板行。
|
|
138
|
+
*
|
|
139
|
+
* 求值本身归 `effective`:面板与装配读同一份结论,这里只把清单上的展示字段拼上去。
|
|
140
|
+
* `scope` 决定用哪条链:`workspace` 走「绑定 pack → default → 内置缺省」,
|
|
141
|
+
* `global` 直接按 default 求值。
|
|
142
|
+
*/
|
|
143
|
+
function buildRows(rows, state, scope, complete) {
|
|
144
|
+
const answer = effective({ policy: state, names: rows.map((row) => row.name), scope, complete });
|
|
145
|
+
const answers = new Map(answer.rows.map((row) => [row.name, row]));
|
|
146
|
+
const showOfficial = state.ui.showOfficialSkills;
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const row of rows) {
|
|
149
|
+
const official = isOfficialSkill(row);
|
|
150
|
+
// 官方技能只在面板层面过滤;求值始终用全量清单,
|
|
151
|
+
// 否则把「显示官方技能」关回去之后,之前对它们做的决定会失效。
|
|
152
|
+
if (official && !showOfficial)
|
|
153
|
+
continue;
|
|
154
|
+
const item = answers.get(row.name);
|
|
155
|
+
if (item === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
out.push({
|
|
158
|
+
...item,
|
|
159
|
+
description: row.description,
|
|
160
|
+
source: row.source,
|
|
161
|
+
provider: row.provider,
|
|
162
|
+
official,
|
|
163
|
+
origin: originOf(row),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function rootSkills(ctx) {
|
|
169
|
+
const skills = ctx.get('skills');
|
|
170
|
+
if (typeof skills !== 'object' || skills === null)
|
|
171
|
+
return null;
|
|
172
|
+
return skills;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* 挑一个活 Agent,拿它的 scope 读清单。
|
|
176
|
+
*
|
|
177
|
+
* **全局面板也必须带 scope**。`dsh-skill-filesystem` 由 agent preset 挂载,
|
|
178
|
+
* 注册在 preset 层;`snapshot({})` 不传 scope 时只查 global 层,于是只能看到
|
|
179
|
+
* 那些直接注册在根 ctx 的插件 provider(实测 4 个 provider、15 条),
|
|
180
|
+
* 用户级目录(`.agents/skills`)与工程 skill 全部缺失。
|
|
181
|
+
* 关闭决定仍然写在 default pack,所以用会话清单当「看得见的全集」不改变语义。
|
|
182
|
+
*
|
|
183
|
+
* 没有任何活跃会话时退回 global 层:此时只能看到根 provider,面板会偏少,
|
|
184
|
+
* 但那是暂时状态,新建会话即恢复。
|
|
185
|
+
*/
|
|
186
|
+
async function readAnyView(ctx, deps, sessionId) {
|
|
187
|
+
// 优先用调用方给的会话:以前只取 `agents.list()` 的第一个,用户在会话 B 打开面板时
|
|
188
|
+
// 看到的行集合可能来自会话 A 的 preset——状态是全局层的,行却是别人的。
|
|
189
|
+
if (sessionId !== undefined) {
|
|
190
|
+
try {
|
|
191
|
+
const session = await resolveSession(ctx, sessionId);
|
|
192
|
+
// 非 live 的观察没有 scope,只能用它的 cwd;那种情况交给下面的启发式。
|
|
193
|
+
if (session !== null && session.agent !== undefined) {
|
|
194
|
+
return await deps.snapshot.refresh(session.cwd, session.agent);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// 解析失败不让面板整体失败,退回下面的路径。
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const agentsService = ctx.get('agents');
|
|
203
|
+
const list = typeof agentsService?.list === 'function' ? agentsService.list.call(agentsService) : [];
|
|
204
|
+
const agent = Array.isArray(list) ? list[0] : undefined;
|
|
205
|
+
const session = agent?.session;
|
|
206
|
+
const cwd = session?.header?.cwd;
|
|
207
|
+
if (agent !== undefined && typeof cwd === 'string' && cwd !== '') {
|
|
208
|
+
return await deps.snapshot.refresh(cwd, agent);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// 取不到 Agent 就退回全局清单,不让面板整体失败。
|
|
213
|
+
}
|
|
214
|
+
return await deps.snapshot.refresh(null);
|
|
215
|
+
}
|
|
216
|
+
/** `POST api/skill-pack/global`:全部 pack + 组注册表 + ui + 清单。 */
|
|
217
|
+
export async function handleGlobal(ctx, deps, sessionId) {
|
|
218
|
+
try {
|
|
219
|
+
const skills = rootSkills(ctx);
|
|
220
|
+
if (skills === null)
|
|
221
|
+
return { ok: false, error: { code: 'internal', message: 'skills 服务不可用' } };
|
|
222
|
+
const view = await readAnyView(ctx, deps, sessionId);
|
|
223
|
+
const state = view.policy;
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
value: {
|
|
227
|
+
packs: packViews(state.packs),
|
|
228
|
+
ui: state.ui,
|
|
229
|
+
groups: state.groups,
|
|
230
|
+
bindings: state.bindings,
|
|
231
|
+
skills: buildRows(view.rows, state, 'global', view.complete),
|
|
232
|
+
complete: view.complete,
|
|
233
|
+
warnings: view.warnings,
|
|
234
|
+
stuck: deps.stuck(),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return { ok: false, error: { code: 'internal', message: `读取全局策略失败:${String(error)}` } };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function buildWorkspaceView(ctx, cwd, agent, live, stuck, deps) {
|
|
243
|
+
const skills = rootSkills(ctx);
|
|
244
|
+
if (skills === null)
|
|
245
|
+
return { ok: false, error: { code: 'internal', message: 'skills 服务不可用' } };
|
|
246
|
+
// 带 scope 的一次读取已经包含 default 层(`[global, ...chainLayers(scope)]`),
|
|
247
|
+
// 而且与 hooks 共用同一份快照:面板看到的隐藏集就是会话里生效的那一份。
|
|
248
|
+
// 非 live 的会话没有 scope,但 cwd 仍要传:绑定表按 cwd 解析,丢掉它会让
|
|
249
|
+
// 已关闭会话的工作区视图退回 default,显示成「没绑定」。
|
|
250
|
+
const view = await deps.snapshot.refresh(cwd, agent);
|
|
251
|
+
const state = view.policy;
|
|
252
|
+
const defaultPack = defaultPackOf(state.packs);
|
|
253
|
+
return {
|
|
254
|
+
ok: true,
|
|
255
|
+
value: {
|
|
256
|
+
packs: packViews(state.packs),
|
|
257
|
+
ui: state.ui,
|
|
258
|
+
groups: state.groups,
|
|
259
|
+
bindings: state.bindings,
|
|
260
|
+
skills: buildRows(view.rows, state, 'workspace', view.complete),
|
|
261
|
+
complete: view.complete,
|
|
262
|
+
warnings: view.warnings,
|
|
263
|
+
cwd,
|
|
264
|
+
binding: state.binding,
|
|
265
|
+
packId: state.packId,
|
|
266
|
+
packName: state.pack?.name ?? defaultPack.name,
|
|
267
|
+
live,
|
|
268
|
+
hidden: view.hidden,
|
|
269
|
+
stuck,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** `POST api/skill-pack/workspace`:`{ sessionId }`。 */
|
|
274
|
+
export async function handleWorkspace(ctx, body, deps) {
|
|
275
|
+
try {
|
|
276
|
+
const sessionId = readString(body, 'sessionId');
|
|
277
|
+
if (sessionId === null) {
|
|
278
|
+
return { ok: false, error: { code: 'unknown-session', message: '工作区请求需要 sessionId' } };
|
|
279
|
+
}
|
|
280
|
+
const resolved = await resolveSession(ctx, sessionId);
|
|
281
|
+
if (resolved === null) {
|
|
282
|
+
return { ok: false, error: { code: 'unknown-session', message: `未找到会话:${sessionId}` } };
|
|
283
|
+
}
|
|
284
|
+
return await buildWorkspaceView(ctx, resolved.cwd, resolved.agent, resolved.live, deps.stuck(), deps);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return { ok: false, error: { code: 'internal', message: `读取工作区策略失败:${String(error)}` } };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const OPS = new Set([
|
|
291
|
+
'packItem',
|
|
292
|
+
'packGroup',
|
|
293
|
+
'createPack',
|
|
294
|
+
'renamePack',
|
|
295
|
+
'deletePack',
|
|
296
|
+
'bind',
|
|
297
|
+
'addExtra',
|
|
298
|
+
'removeExtra',
|
|
299
|
+
'assign',
|
|
300
|
+
'createGroup',
|
|
301
|
+
'renameGroup',
|
|
302
|
+
'deleteGroup',
|
|
303
|
+
'showOfficialSkills',
|
|
304
|
+
'showChip',
|
|
305
|
+
'defaultTab',
|
|
306
|
+
]);
|
|
307
|
+
function readNonEmpty(record, key) {
|
|
308
|
+
const value = record[key];
|
|
309
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
310
|
+
}
|
|
311
|
+
function readValue(record) {
|
|
312
|
+
const value = record['value'];
|
|
313
|
+
if (value === null)
|
|
314
|
+
return null;
|
|
315
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
316
|
+
}
|
|
317
|
+
/** 校验入站意图。宁可拒绝整批,也不要半懂地写一半。 */
|
|
318
|
+
export function parseCommands(raw) {
|
|
319
|
+
if (!Array.isArray(raw))
|
|
320
|
+
return { ok: false, message: 'commands 必须是数组' };
|
|
321
|
+
if (raw.length === 0)
|
|
322
|
+
return { ok: false, message: 'commands 不能为空' };
|
|
323
|
+
const commands = [];
|
|
324
|
+
const bad = (index, message) => ({ ok: false, message: `commands[${index}] ${message}` });
|
|
325
|
+
for (const [index, entry] of raw.entries()) {
|
|
326
|
+
const record = asRecord(entry);
|
|
327
|
+
if (record === null)
|
|
328
|
+
return bad(index, '不是对象');
|
|
329
|
+
const op = record['op'];
|
|
330
|
+
if (typeof op !== 'string' || !OPS.has(op))
|
|
331
|
+
return bad(index, 'op 不是已知操作');
|
|
332
|
+
switch (op) {
|
|
333
|
+
case 'showOfficialSkills':
|
|
334
|
+
case 'showChip': {
|
|
335
|
+
const value = record['value'];
|
|
336
|
+
if (typeof value !== 'boolean')
|
|
337
|
+
return bad(index, 'value 必须是布尔');
|
|
338
|
+
commands.push({ op, value });
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case 'defaultTab': {
|
|
342
|
+
const value = record['value'];
|
|
343
|
+
if (value !== 'groups' && value !== 'packs' && value !== 'workspace') {
|
|
344
|
+
return bad(index, 'value 必须是 groups、packs 或 workspace');
|
|
345
|
+
}
|
|
346
|
+
commands.push({ op: 'defaultTab', value });
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'createPack': {
|
|
350
|
+
const name = record['name'];
|
|
351
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
352
|
+
return bad(index, 'name 必须是非空字符串');
|
|
353
|
+
commands.push({ op: 'createPack', name });
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case 'renamePack': {
|
|
357
|
+
const packId = readNonEmpty(record, 'packId');
|
|
358
|
+
if (packId === null)
|
|
359
|
+
return bad(index, 'packId 必须是非空字符串');
|
|
360
|
+
const name = record['name'];
|
|
361
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
362
|
+
return bad(index, 'name 必须是非空字符串');
|
|
363
|
+
commands.push({ op: 'renamePack', packId, name });
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case 'deletePack': {
|
|
367
|
+
const packId = readNonEmpty(record, 'packId');
|
|
368
|
+
if (packId === null)
|
|
369
|
+
return bad(index, 'packId 必须是非空字符串');
|
|
370
|
+
commands.push({ op: 'deletePack', packId });
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'packItem': {
|
|
374
|
+
const packId = readNonEmpty(record, 'packId');
|
|
375
|
+
if (packId === null)
|
|
376
|
+
return bad(index, 'packId 必须是非空字符串');
|
|
377
|
+
const name = readNonEmpty(record, 'name');
|
|
378
|
+
if (name === null)
|
|
379
|
+
return bad(index, 'name 必须是非空字符串');
|
|
380
|
+
const value = readValue(record);
|
|
381
|
+
if (value === undefined)
|
|
382
|
+
return bad(index, 'value 必须是布尔或 null');
|
|
383
|
+
commands.push({ op: 'packItem', packId, name, value });
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case 'packGroup': {
|
|
387
|
+
const packId = readNonEmpty(record, 'packId');
|
|
388
|
+
if (packId === null)
|
|
389
|
+
return bad(index, 'packId 必须是非空字符串');
|
|
390
|
+
const groupId = readNonEmpty(record, 'groupId');
|
|
391
|
+
if (groupId === null)
|
|
392
|
+
return bad(index, 'groupId 必须是非空字符串');
|
|
393
|
+
const value = readValue(record);
|
|
394
|
+
if (value === undefined)
|
|
395
|
+
return bad(index, 'value 必须是布尔或 null');
|
|
396
|
+
commands.push({ op: 'packGroup', packId, groupId, value });
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case 'bind': {
|
|
400
|
+
const cwd = readNonEmpty(record, 'cwd');
|
|
401
|
+
if (cwd === null || cwd.trim() === '')
|
|
402
|
+
return bad(index, 'cwd 必须是非空字符串');
|
|
403
|
+
const packId = record['packId'];
|
|
404
|
+
if (packId !== null && (typeof packId !== 'string' || packId === '')) {
|
|
405
|
+
return bad(index, 'packId 必须是 pack id 或 null');
|
|
406
|
+
}
|
|
407
|
+
commands.push({ op: 'bind', cwd, packId });
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case 'addExtra':
|
|
411
|
+
case 'removeExtra': {
|
|
412
|
+
const cwd = readNonEmpty(record, 'cwd');
|
|
413
|
+
if (cwd === null || cwd.trim() === '')
|
|
414
|
+
return bad(index, 'cwd 必须是非空字符串');
|
|
415
|
+
commands.push({ op, cwd });
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case 'assign': {
|
|
419
|
+
const name = readNonEmpty(record, 'name');
|
|
420
|
+
if (name === null)
|
|
421
|
+
return bad(index, 'name 必须是非空字符串');
|
|
422
|
+
const groupId = record['groupId'];
|
|
423
|
+
if (groupId !== null && (typeof groupId !== 'string' || groupId === '')) {
|
|
424
|
+
return bad(index, 'groupId 必须是分组 id 或 null');
|
|
425
|
+
}
|
|
426
|
+
commands.push({ op: 'assign', name, groupId });
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case 'createGroup':
|
|
430
|
+
case 'renameGroup': {
|
|
431
|
+
const name = record['name'];
|
|
432
|
+
if (typeof name !== 'string' || name.trim() === '')
|
|
433
|
+
return bad(index, 'name 必须是非空字符串');
|
|
434
|
+
if (op === 'createGroup') {
|
|
435
|
+
commands.push({ op: 'createGroup', name });
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
const groupId = readNonEmpty(record, 'groupId');
|
|
439
|
+
if (groupId === null)
|
|
440
|
+
return bad(index, 'groupId 必须是非空字符串');
|
|
441
|
+
commands.push({ op: 'renameGroup', groupId, name });
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'deleteGroup': {
|
|
445
|
+
const groupId = readNonEmpty(record, 'groupId');
|
|
446
|
+
if (groupId === null)
|
|
447
|
+
return bad(index, 'groupId 必须是非空字符串');
|
|
448
|
+
commands.push({ op: 'deleteGroup', groupId });
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
default:
|
|
452
|
+
return bad(index, 'op 不是已知操作');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, commands };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* 把这次写入产生的告警并进视图。
|
|
459
|
+
*
|
|
460
|
+
* 写失败不改信封:`ok: false` 留给命令解析失败与未知会话,写盘失败是「这次没写成」
|
|
461
|
+
* 而不是「请求无效」,它必须和读警告一起出现在同一个 `warnings` 里。
|
|
462
|
+
*/
|
|
463
|
+
function withWriteWarnings(view, warnings) {
|
|
464
|
+
if (!view.ok)
|
|
465
|
+
return view;
|
|
466
|
+
const merged = [...warnings];
|
|
467
|
+
for (const warning of view.value.warnings) {
|
|
468
|
+
if (merged.indexOf(warning) < 0)
|
|
469
|
+
merged.push(warning);
|
|
470
|
+
}
|
|
471
|
+
const value = { ...view.value, warnings: merged };
|
|
472
|
+
return { ok: true, value };
|
|
473
|
+
}
|
|
474
|
+
/** `POST api/skill-pack/apply`:`{ sessionId?, commands }`。 */
|
|
475
|
+
export async function handleApply(ctx, body, deps) {
|
|
476
|
+
try {
|
|
477
|
+
const parsed = parseCommands(body?.['commands']);
|
|
478
|
+
if (!parsed.ok)
|
|
479
|
+
return { ok: false, error: { code: 'bad-request', message: parsed.message } };
|
|
480
|
+
// `bind` 自带 cwd,所以写入本身不再依赖会话;sessionId 只决定回哪种视图。
|
|
481
|
+
const sessionId = readString(body, 'sessionId');
|
|
482
|
+
let target = null;
|
|
483
|
+
if (sessionId !== null) {
|
|
484
|
+
try {
|
|
485
|
+
target = await resolveSession(ctx, sessionId);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// 会话解析失败不影响写入:绑定命令带自己的 cwd。
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// 写入失败不抛,只把说明塞进 state.warnings;下面把它并进返回的视图。
|
|
492
|
+
const state = await applyCommands(target?.cwd ?? null, parsed.commands);
|
|
493
|
+
// 保存不触碰 skill 注册表,因此不会发出 skills/change;这条显式调用是
|
|
494
|
+
// 「同一会话内改策略立刻生效」的唯一来源。失败只降级,不能让写成功的保存报错。
|
|
495
|
+
try {
|
|
496
|
+
await deps.reapply();
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
ctx.logger.warn(`dsh-skill-pack: 保存后重新落地失败(策略已写入):${String(error)}`);
|
|
500
|
+
}
|
|
501
|
+
if (target === null)
|
|
502
|
+
return withWriteWarnings(await handleGlobal(ctx, deps), state.warnings);
|
|
503
|
+
const view = await buildWorkspaceView(ctx, target.cwd, target.agent, target.live, deps.stuck(), deps);
|
|
504
|
+
return withWriteWarnings(view, state.warnings);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
// 写入失败已经在 warnings 里,走不到这里;命令解析失败走上面的 bad-request。
|
|
508
|
+
// 这条只剩渲染最新视图时抛错,属于意外,保留一个兜底信封。
|
|
509
|
+
return { ok: false, error: { code: 'internal', message: `读取最新状态失败:${String(error)}` } };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Inventory, SkillSummaryLike } from './inventory.js';
|
|
2
|
+
import { type PolicyState } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* 一份已求值的快照:清单 + pack 策略 + 隐藏集。
|
|
5
|
+
*
|
|
6
|
+
* 这是三个 manager 共有的形状:**求值一次,落地多次**。落地(注册遮蔽副本、
|
|
7
|
+
* 施加工具限制、注册名册段)必须 per-agent,因为它依赖各自的 ctx;但求值只
|
|
8
|
+
* 依赖 cwd 与 scope,与 agent 数量无关。
|
|
9
|
+
*/
|
|
10
|
+
export interface SnapshotValue {
|
|
11
|
+
/** 全量清单,含官方技能:求值始终用它,面板展示与否是另一回事。 */
|
|
12
|
+
readonly rows: readonly SkillSummaryLike[];
|
|
13
|
+
/** 清单是否完整。不完整时 `hidden` 恒为空——宁可这一轮不动,也不要误放行。 */
|
|
14
|
+
readonly complete: boolean;
|
|
15
|
+
/** 已被关闭、落地时要遮蔽的名字,已排序。 */
|
|
16
|
+
readonly hidden: readonly string[];
|
|
17
|
+
readonly policy: PolicyState;
|
|
18
|
+
readonly warnings: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/** 快照的依赖。全部可注入,测试不必碰真实文件系统。 */
|
|
21
|
+
export interface SnapshotDeps {
|
|
22
|
+
/** 读 pack 策略与组定义。真实实现是 `loadState`。 */
|
|
23
|
+
readonly loadPolicy: (cwd: string | null) => Promise<PolicyState>;
|
|
24
|
+
/** 读 skill 清单。真实实现绕开旧的模块级缓存,直接 `skills.snapshot()`。 */
|
|
25
|
+
readonly readInventory: (cwd: string | null, scope: unknown) => Promise<Inventory>;
|
|
26
|
+
/** 文件指纹。缺省用 size + mtime;测试注入固定值即可控制命中与未命中。 */
|
|
27
|
+
readonly fingerprint?: (cwd: string | null) => Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
export interface ScopeSnapshot {
|
|
30
|
+
/** 异步求值。指纹没变就直接复用,变了才读盘;同键并发只算一次。 */
|
|
31
|
+
refresh(cwd: string | null, scope?: unknown, options?: {
|
|
32
|
+
readonly force?: boolean;
|
|
33
|
+
}): Promise<SnapshotValue>;
|
|
34
|
+
/** 打掉缓存。省略 `cwd` 清全部;传 `null` 只清全局那一格。 */
|
|
35
|
+
invalidate(cwd?: string | null): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 三份状态文件的指纹。
|
|
39
|
+
*
|
|
40
|
+
* **只做兜底**:同进程写入用 `invalidate()` 显式打掉,不依赖时间戳。这里覆盖的是
|
|
41
|
+
* 「用户在编辑器里手改了文件」——那种改动没有事件可订阅。
|
|
42
|
+
*
|
|
43
|
+
* 已知盲区:同毫秒内改完且字节数不变时指纹一样。显式失效路径能覆盖同进程写入,
|
|
44
|
+
* 所以这个盲区只在「外部手改 + 恰好同毫秒同字节数」时成立,代价是旧值多活一轮。
|
|
45
|
+
*/
|
|
46
|
+
export declare function fingerprintOf(cwd: string | null): Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* 建一个按 cwd + scope 索引的求值缓存。
|
|
49
|
+
*
|
|
50
|
+
* 三条性质:
|
|
51
|
+
* 1. **`read()` 零 IO**:只用内存,供同步装配路径使用。
|
|
52
|
+
* 2. **`refresh()` 单飞**:同键并发请求共用一次计算,不会 N 份重复读盘。
|
|
53
|
+
* 3. **失效分两种**:显式 `invalidate()` 不依赖时间戳;指纹只兜外部手改。
|
|
54
|
+
*/
|
|
55
|
+
export declare function createScopeSnapshot(deps: SnapshotDeps): ScopeSnapshot;
|