dsh-subagent-profile 0.3.4 → 0.5.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.
Files changed (38) hide show
  1. package/README.md +13 -8
  2. package/README.zh.md +13 -8
  3. package/index.mjs +99 -95
  4. package/lib/client.js +1288 -189
  5. package/lib/core/decision-trace.mjs +20 -1
  6. package/lib/core/delegation.mjs +3 -2
  7. package/lib/core/dispatch-gates.mjs +122 -9
  8. package/lib/core/dispatch-schema.mjs +45 -7
  9. package/lib/core/dispatch-tool.mjs +24 -25
  10. package/lib/core/evolution-advice.mjs +154 -34
  11. package/lib/core/evolution-assets.mjs +167 -0
  12. package/lib/core/evolution-canary.mjs +427 -0
  13. package/lib/core/evolution-draft.mjs +333 -0
  14. package/lib/core/evolution-engine.mjs +413 -0
  15. package/lib/core/evolution-generate.mjs +180 -0
  16. package/lib/core/evolution-lag.mjs +78 -0
  17. package/lib/core/evolution-ledger.mjs +135 -27
  18. package/lib/core/evolution-persistence.mjs +213 -0
  19. package/lib/core/evolution-renewal.mjs +64 -0
  20. package/lib/core/evolution-routes.mjs +234 -0
  21. package/lib/core/http-helpers.mjs +35 -0
  22. package/lib/core/http-routes.mjs +35 -39
  23. package/lib/core/model-policy.mjs +77 -0
  24. package/lib/core/presets-sync.mjs +24 -2
  25. package/lib/core/profile-directory.mjs +17 -0
  26. package/lib/core/profile-provider.mjs +9 -3
  27. package/lib/core/profiles-query.mjs +178 -0
  28. package/lib/core/profiles-store.mjs +56 -8
  29. package/lib/core/pure.mjs +1 -1
  30. package/lib/core/session-read.mjs +17 -0
  31. package/lib/core/shims.mjs +2 -1
  32. package/package.json +2 -2
  33. package/presets/orchestrator-v2/NOTICE +7 -0
  34. package/presets/{orchestrator → orchestrator-v2}/agent.cordis.yml +78 -7
  35. package/presets/orchestrator-v2/custom-bash.mjs +213 -0
  36. package/presets/orchestrator-v2/preset.yml +2 -0
  37. package/presets/orchestrator-v2/tool-bootstrap.mjs +620 -0
  38. package/presets/orchestrator/preset.yml +0 -2
@@ -0,0 +1,234 @@
1
+ // lib/core/evolution-routes.mjs — 进化资产与候选管理路由处理:/evolution/assets 只读
2
+ // 列表(含金丝雀回滚建议数据源)、/evolution/apply 应用写路径、/evolution/rollback
3
+ // 决议写路径(回滚/显式保留)、/evolution/regenerate 重新生成候选、/advice/save-profile 保存为方案、
4
+ // /settings/candidate 候选设置。loopback 与 CSRF 守卫由 createHttpRoutes 入口统一
5
+ // 完成,本模块只做 body 解析与响应组装。apply/rollback 拒绝(400)回传原因;成功
6
+ // 回传新方案 id、资产状态与 persisted 信号。业务本体(三道闸/审计/锁/金丝雀判定)
7
+ // 在 evolution-engine 与 evolution-canary 内,本模块不触碰任何闸判定。
8
+
9
+ import { sanitizeProfile } from './pure.mjs';
10
+ import { computeAdvice } from './evolution-advice.mjs';
11
+ import { lowerHyphen, sourceConfigFromKey } from './evolution-draft.mjs';
12
+ import { rollbackSuggestionOf } from './evolution-canary.mjs';
13
+ import { json, readBody, persistOk } from './http-helpers.mjs';
14
+
15
+ // ── 资产只读视图(黑盒闭合:观察期/轨迹/徽标统一由 /evolution/assets 下发)──────
16
+
17
+ // 观察期状态视图:state/label 由后端给出(用户可见面零内部词);baseline/post 供
18
+ // 撤销建议区证据对照;kept = 用户已显式保留。pendingApply = 候选未应用。
19
+ const CANARY_VIEW_LABELS = {
20
+ pendingApply: '未应用',
21
+ observing: '观察中',
22
+ stalled: '观察数据不足(未判定),等待更多派发',
23
+ insufficient: '观察数据不足(未判定),等待更多派发',
24
+ inconclusive: '观察数据不足(未判定),等待更多派发',
25
+ applied: '观察期结束:表现未下降,已保留',
26
+ regress: '观察期结束:上次的调整表现变差了',
27
+ aborted: '你手动修改了方案,观察已中止(不会再判定)',
28
+ };
29
+
30
+ export function canaryViewOf(asset) {
31
+ const baseline = asset !== null && typeof asset === 'object' && asset.baseline_metrics !== null && typeof asset.baseline_metrics === 'object' ? asset.baseline_metrics : {};
32
+ const canary = asset !== null && typeof asset === 'object' && asset.canary !== null && typeof asset.canary === 'object' ? asset.canary : null;
33
+ if (canary === null) {
34
+ return { state: 'pendingApply', k: 0, kTotal: 0, verdict: null, label: CANARY_VIEW_LABELS.pendingApply, baseline: { sampleN: baseline.sampleN ?? null, weighted_success: baseline.weighted_success ?? null }, post: null, kept: false };
35
+ }
36
+ const k = Number.isFinite(canary.k) ? canary.k : 0;
37
+ const kTotal = Number.isFinite(canary.kTotal) ? canary.kTotal : 0;
38
+ const samples = Array.isArray(canary.samples) ? canary.samples : [];
39
+ const last = samples.length > 0 && samples[samples.length - 1] !== null && typeof samples[samples.length - 1] === 'object' ? samples[samples.length - 1] : null;
40
+ const kept = asset.keep_at !== undefined || canary.kept_at !== undefined;
41
+ const resolvedRollback = asset.state === 'rolled_back' || Number.isFinite(canary.rollback_at);
42
+ let state = 'observing';
43
+ let label = null;
44
+ if (resolvedRollback) { state = 'applied'; label = '已撤销调整,观察期终止'; }
45
+ else if (kept) { state = 'applied'; label = '观察期结束:已选择继续使用当前配置'; }
46
+ else if (canary.verdict === 'stalled') state = 'stalled';
47
+ else if (canary.insufficient === true || canary.verdict === 'inconclusive') state = 'inconclusive';
48
+ else if (canary.verdict === 'regress') state = 'regress';
49
+ else if (canary.verdict === 'aborted-human-edit') state = 'aborted';
50
+ else if (asset.state === 'applied') state = 'applied';
51
+ if (label === null) label = state === 'observing' ? CANARY_VIEW_LABELS.observing + '(' + k + '/' + kTotal + ')' : CANARY_VIEW_LABELS[state];
52
+ return {
53
+ state,
54
+ k,
55
+ kTotal,
56
+ verdict: canary.verdict ?? null,
57
+ label,
58
+ baseline: { sampleN: baseline.sampleN ?? null, weighted_success: baseline.weighted_success ?? null },
59
+ post: last === null ? null : { sampleN: last.sampleN, weighted_success: last.weighted_success },
60
+ kept,
61
+ };
62
+ }
63
+
64
+ // 判定短标签(进化轨迹「判定:…」用;未判定空串)。
65
+ function verdictShortLabel(verdict) {
66
+ if (verdict === 'regress') return '建议撤销';
67
+ if (verdict === 'applied') return '表现未下降,已保留';
68
+ if (verdict === 'aborted-human-edit') return '观察中止';
69
+ if (verdict === 'stalled' || verdict === 'inconclusive') return '数据不足,未判定';
70
+ return '';
71
+ }
72
+
73
+ // 进化轨迹(≤8 条,按 ts 升序):生成 → 应用(人工确认)→ 观察(k/kTotal)→
74
+ // 判定(短标签)→ 处理(你选择撤销/保留)。无 canary 的资产只有生成事件。
75
+ export function lifecycleOf(asset) {
76
+ const events = [];
77
+ const canary = asset !== null && typeof asset === 'object' && asset.canary !== null && typeof asset.canary === 'object' ? asset.canary : null;
78
+ if (asset !== null && typeof asset === 'object' && Number.isFinite(asset.createdAt)) events.push({ ts: asset.createdAt, event: 'generated' });
79
+ if (asset !== null && typeof asset === 'object' && Number.isFinite(asset.applied_at)) events.push({ ts: asset.applied_at, event: 'applied', meta: '人工确认' });
80
+ if (canary !== null) {
81
+ const samples = Array.isArray(canary.samples) ? canary.samples : [];
82
+ const last = samples.length > 0 && Number.isFinite(samples[samples.length - 1]?.ts) ? samples[samples.length - 1].ts : asset.applied_at;
83
+ if (Number.isFinite(last)) events.push({ ts: last, event: 'observed', meta: (Number.isFinite(canary.k) ? canary.k : 0) + '/' + (Number.isFinite(canary.kTotal) ? canary.kTotal : 0) });
84
+ if (Number.isFinite(canary.judged_at)) {
85
+ const verdict = verdictShortLabel(canary.verdict);
86
+ events.push({ ts: canary.judged_at, event: 'judged', meta: verdict !== '' ? verdict : '数据不足,未判定' });
87
+ }
88
+ if (Number.isFinite(canary.rollback_at)) events.push({ ts: canary.rollback_at, event: 'resolved', meta: '你选择撤销调整' });
89
+ else if (canary.kept_at !== undefined) events.push({ ts: canary.kept_at, event: 'resolved', meta: '你选择保留' });
90
+ }
91
+ events.sort((a, b) => a.ts - b.ts);
92
+ return events.slice(-8);
93
+ }
94
+
95
+ // 候选行生命周期徽标(后端给中文;未知状态不渲染)。
96
+ export function assetStateLabelOf(asset) {
97
+ if (asset === null || typeof asset !== 'object') return null;
98
+ const canary = asset.canary !== null && typeof asset.canary === 'object' ? asset.canary : null;
99
+ if (asset.invalid === true) return '观察中止';
100
+ if (asset.state === 'draft') return '草稿';
101
+ if (asset.state === 'expired') return '已过期';
102
+ if (asset.state === 'rolled_back') return '已撤销';
103
+ if (asset.state === 'applied') return canary !== null && canary.kept_at !== undefined ? '已保留' : '已应用';
104
+ if (asset.state === 'proposed') return canary !== null && canary.verdict === 'regress' ? '待撤销' : '观察中';
105
+ return null;
106
+ }
107
+
108
+ async function handleEvolutionAssets(deps, res) {
109
+ // 金丝雀判定随只读列表触发(幂等;失败只 warn 不阻断列表)。
110
+ if (typeof deps.evolution?.evaluateCanaries === 'function') {
111
+ try { deps.evolution.evaluateCanaries(); } catch { /* fail-soft,判定失败不阻断只读列表 */ }
112
+ }
113
+ const raw = deps.evolution !== undefined && typeof deps.evolution.list === 'function' ? deps.evolution.list() : [];
114
+ const rollbackSuggestions = raw.map((asset) => rollbackSuggestionOf(asset)).filter((s) => s !== null);
115
+ const assets = raw.map((asset) => ({ ...asset, canary: canaryViewOf(asset), lifecycle: lifecycleOf(asset), stateLabel: assetStateLabelOf(asset) }));
116
+ return json(res, 200, { ok: true, assets, rollbackSuggestions });
117
+ }
118
+
119
+ async function handleEvolutionApply(deps, req, res) {
120
+ const body = await readBody(req);
121
+ const assetId = typeof body.assetId === 'string' ? body.assetId : '';
122
+ if (assetId === '') return json(res, 400, { ok: false, error: 'assetId 不能为空' });
123
+ if (deps.evolution === undefined || typeof deps.evolution.apply !== 'function') {
124
+ return json(res, 404, { ok: false, error: '进化 apply 引擎未装配' });
125
+ }
126
+ const result = await deps.evolution.apply({ assetId, humanConfirmed: body.human_confirmed === true });
127
+ if (!result.ok) {
128
+ const payload = { ok: false, error: result.reason };
129
+ if (result.checks !== undefined) payload.checks = result.checks;
130
+ return json(res, 400, payload);
131
+ }
132
+ return json(res, 200, {
133
+ ok: true,
134
+ id: result.id,
135
+ state: result.state,
136
+ persisted: result.persisted,
137
+ ...(result.persisted ? {} : { persistWarning: '已应用但未持久化' }),
138
+ });
139
+ }
140
+
141
+ // 候选设置写路径:更新模式(自动换新/手动维护)+ 有效期小时数(0 或 1~8760 整数)。
142
+ // 校验失败 400;成功持久化并回传 persisted 信号与归一后的值。
143
+ async function handleSettingsCandidate(deps, req, res) {
144
+ const body = await readBody(req);
145
+ const mode = body && typeof body.candidateMode === 'string' ? body.candidateMode : '';
146
+ const ttlH = body && typeof body.candidateTtlH === 'number' ? body.candidateTtlH : NaN;
147
+ if (mode !== 'auto' && mode !== 'manual') {
148
+ return json(res, 400, { ok: false, error: '候选更新模式无效(auto/manual)' });
149
+ }
150
+ if (!Number.isInteger(ttlH) || !(ttlH === 0 || (ttlH >= 1 && ttlH <= 8760))) {
151
+ return json(res, 400, { ok: false, error: '候选有效期无效(0 或 1~8760 小时整数)' });
152
+ }
153
+ const modePersist = deps.store.persistCandidateMode(mode);
154
+ const ttlPersist = deps.store.persistCandidateTtlH(ttlH);
155
+ const persisted = modePersist.persisted !== false && ttlPersist.persisted !== false;
156
+ return persistOk(res, {
157
+ candidateMode: deps.store.loadCandidateMode(),
158
+ candidateTtlH: deps.store.loadCandidateTtlH(),
159
+ }, { persisted });
160
+ }
161
+
162
+ // 决议执行(人工确认):action='keep' 显式保留当前配置(不写 profile,资产
163
+ // proposed→applied + suggestion-keep 审计);缺省 action='rollback' 走恢复快照。
164
+ // 非法 action 400;拒绝分支回传原因。审计与恢复本体在 evolution-canary 内。
165
+ async function handleEvolutionRollback(deps, req, res) {
166
+ const body = await readBody(req);
167
+ const assetId = typeof body.assetId === 'string' ? body.assetId : '';
168
+ if (assetId === '') return json(res, 400, { ok: false, error: 'assetId 不能为空' });
169
+ const rawAction = typeof body.action === 'string' ? body.action : '';
170
+ const action = rawAction === '' ? 'rollback' : (rawAction === 'rollback' || rawAction === 'keep' ? rawAction : '');
171
+ if (action === '') return json(res, 400, { ok: false, error: 'action 无效(rollback/keep)' });
172
+ if (deps.evolution === undefined || typeof deps.evolution.rollback !== 'function') {
173
+ return json(res, 404, { ok: false, error: '进化决议引擎未装配' });
174
+ }
175
+ const result = await deps.evolution.rollback({ assetId, humanConfirmed: body.human_confirmed === true, action });
176
+ if (!result.ok) return json(res, 400, { ok: false, error: result.reason });
177
+ const payload = { ok: true, id: assetId, state: result.state };
178
+ if (result.restored !== undefined) payload.restored = result.restored;
179
+ if (result.persisted !== undefined) {
180
+ payload.persisted = result.persisted;
181
+ if (!result.persisted) payload.persistWarning = '已回滚但未持久化';
182
+ }
183
+ return json(res, 200, payload);
184
+ }
185
+
186
+ // 重新生成候选(人工触发):该身份键所有未处置候选先转 expired,再按当前建议
187
+ // 产新一代;审计由引擎写 suggestion-refresh。无当前建议时只清理不产新候选。
188
+ async function handleEvolutionRegenerate(deps, req, res) {
189
+ const body = await readBody(req);
190
+ const profileKey = body && typeof body.profileKey === 'string' ? body.profileKey : '';
191
+ if (profileKey === '') return json(res, 400, { ok: false, error: 'profileKey 不能为空' });
192
+ if (deps.evolution === undefined || typeof deps.evolution.regenerate !== 'function') {
193
+ return json(res, 404, { ok: false, error: '候选重新生成入口未装配' });
194
+ }
195
+ const computed = computeAdvice({ summariesFile: deps.summariesFile, dispatchFile: deps.dispatchFile, whitelist: deps.adviceWhitelist, logger: deps.logger });
196
+ const item = (Array.isArray(computed.advice) ? computed.advice : []).find((a) => a !== null && typeof a === 'object' && a.profileKey === profileKey);
197
+ const result = await deps.evolution.regenerate({ profileKey, advice: item !== undefined ? [item] : [], summaries: computed.summaries });
198
+ return json(res, 200, { ok: true, ...result });
199
+ }
200
+
201
+ // 保存为方案:把建议来源的当前派发配置(身份键可还原的预设/提供方/模型段)存成
202
+ // 新方案,便于复用与对比。自定义人格与工具过滤原文未留存(台账只存存在性),
203
+ // 不在新方案内。身份键无可还原字段时 400。
204
+ async function handleAdviceSaveProfile(deps, req, res) {
205
+ const body = await readBody(req);
206
+ const profileKey = body && typeof body.profileKey === 'string' ? body.profileKey : '';
207
+ if (profileKey === '') return json(res, 400, { ok: false, error: 'profileKey 不能为空' });
208
+ const from = sourceConfigFromKey(profileKey, {});
209
+ const config = {};
210
+ for (const field of ['preset', 'provider', 'model']) {
211
+ if (typeof from[field] === 'string' && from[field] !== '') config[field] = from[field];
212
+ }
213
+ if (Object.keys(config).length === 0) {
214
+ return json(res, 400, { ok: false, error: '该配置无可保存字段(未指定预设/提供方/模型)' });
215
+ }
216
+ const slug = lowerHyphen(profileKey).slice(0, 24) || 'config';
217
+ const base = `advice-${slug}`.slice(0, 32);
218
+ const taken = new Set(deps.store.profiles.keys());
219
+ let id = base;
220
+ for (let attempt = 2; taken.has(id) && attempt <= 6; attempt += 1) {
221
+ id = `${base}-${attempt}`.slice(0, 32);
222
+ }
223
+ if (taken.has(id)) return json(res, 400, { ok: false, error: '方案编号冲突,无法保存' });
224
+ const name = slug.length > 0 ? `建议配置-${slug.slice(0, 16)}` : '建议配置';
225
+ const { clean, warnings } = sanitizeProfile({ ...config, id, name, description: '来自建议面板的配置快照(仅预设/模型/提供方;人格与工具过滤不落盘,按隐私设计)。便于复用与对比分析。' }, { strict: true });
226
+ if (warnings.length > 0) {
227
+ const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
228
+ return json(res, 400, { ok: false, error: `保存被拒绝:${detail}` });
229
+ }
230
+ deps.store.profiles.set(clean.id, { ...clean, persisted: true });
231
+ return persistOk(res, { id: clean.id, name: clean.name }, deps.store.persistProfiles());
232
+ }
233
+
234
+ export { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRollback, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate };
@@ -0,0 +1,35 @@
1
+ // lib/core/http-helpers.mjs — 设置 HTTP 路由的共享小助手(从 http-routes 逐字移出):
2
+ // JSON 响应、POST body 解析(1MB 上限)、persistOk 包装(persisted:false 时回传
3
+ // 「已保存但未持久化」警示)。零依赖,多个路由模块共用。
4
+
5
+ function json(res, code, data) {
6
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
7
+ res.end(JSON.stringify(data));
8
+ }
9
+
10
+ function readBody(req) {
11
+ return new Promise((resolve, reject) => {
12
+ let data = '';
13
+ let size = 0;
14
+ req.on('data', (chunk) => {
15
+ size += chunk.length;
16
+ if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
17
+ data += chunk;
18
+ });
19
+ req.on('end', () => {
20
+ try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
21
+ });
22
+ req.on('error', reject);
23
+ });
24
+ }
25
+
26
+ function persistOk(res, payload, persist) {
27
+ return json(res, 200, {
28
+ ok: true,
29
+ ...payload,
30
+ persisted: persist.persisted,
31
+ ...(persist.persisted ? {} : { persistWarning: '已保存但未持久化' }),
32
+ });
33
+ }
34
+
35
+ export { json, readBody, persistOk };
@@ -3,11 +3,13 @@
3
3
  // syncTool、catalog、logger;写路由各抽为模块级处理函数,工厂返回 scope.effect 设置函数。
4
4
 
5
5
  import { sanitizeProfile } from './pure.mjs';
6
- import { BUILTIN_SEEDS } from './profiles-store.mjs';
6
+ import { BUILTIN_SEEDS, assertProfileDescription } from './profiles-store.mjs';
7
7
  import { detectVersions } from './shims.mjs';
8
- import { readSummaries, computeAdvice } from './evolution-advice.mjs';
8
+ import { readSummaries } from './evolution-advice.mjs';
9
9
  import { profileStatsFromSummaries } from './profile-directory.mjs';
10
10
  import { costSummaryFromSummaries } from './cost-evidence.mjs';
11
+ import { json, readBody, persistOk } from './http-helpers.mjs';
12
+ import { handleEvolutionAssets, handleEvolutionApply, handleEvolutionRollback, handleEvolutionRegenerate, handleAdviceSaveProfile, handleSettingsCandidate } from './evolution-routes.mjs';
11
13
 
12
14
  // 只有 loopback 接口可以驱动设置 HTTP 路由。
13
15
  const LOOPBACKS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -19,36 +21,6 @@ const CSRF_HEADER = 'X-DSH-Plugin';
19
21
  const CSRF_HEADER_KEY = CSRF_HEADER.toLowerCase();
20
22
  const CSRF_HEADER_VALUE = 'dsh-subagent-profile';
21
23
 
22
- function json(res, code, data) {
23
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
24
- res.end(JSON.stringify(data));
25
- }
26
-
27
- function readBody(req) {
28
- return new Promise((resolve, reject) => {
29
- let data = '';
30
- let size = 0;
31
- req.on('data', (chunk) => {
32
- size += chunk.length;
33
- if (size > 1 << 20) { reject(new Error('请求体过大')); req.destroy(); return; }
34
- data += chunk;
35
- });
36
- req.on('end', () => {
37
- try { resolve(data === '' ? {} : JSON.parse(data)); } catch { reject(new Error('请求体不是合法 JSON')); }
38
- });
39
- req.on('error', reject);
40
- });
41
- }
42
-
43
- function persistOk(res, payload, persist) {
44
- return json(res, 200, {
45
- ok: true,
46
- ...payload,
47
- persisted: persist.persisted,
48
- ...(persist.persisted ? {} : { persistWarning: '已保存但未持久化' }),
49
- });
50
- }
51
-
52
24
  function listClean(store, stats) {
53
25
  const map = stats instanceof Map ? stats : new Map();
54
26
  return [...store.profiles.values()].map((profile) => {
@@ -72,18 +44,26 @@ async function handleList(deps, res) {
72
44
  let costSummary = { global: null, byModel: [] };
73
45
  let advice = [];
74
46
  let adviceGlobal = null;
47
+ let evolutionCandidates = [];
48
+ let adviceDetails = {};
75
49
  try {
76
50
  if (typeof deps.refreshAdvice === 'function') deps.refreshAdvice();
77
51
  const summaries = typeof deps.summariesFile === 'string' ? readSummaries(deps.summariesFile, deps.logger) : null;
78
52
  stats = profileStatsFromSummaries(deps.store, summaries);
79
53
  costSummary = costSummaryFromSummaries(summaries);
80
- if (deps.adviceWhitelist !== undefined && typeof deps.dispatchFile === 'string') {
81
- const result = computeAdvice({ summariesFile: deps.summariesFile, dispatchFile: deps.dispatchFile, whitelist: deps.adviceWhitelist, logger: deps.logger });
54
+ if (deps.adviceWhitelist !== undefined && typeof deps.dispatchFile === 'string' && typeof deps.adviceSource === 'function') {
55
+ // 建议 + 候选生成统一走装配层 helper(computeAdvice 之后推导候选资产),
56
+ // 避免 /list 与其它消费点重复生成;候选随建议链开关同生共死。
57
+ const result = await deps.adviceSource();
82
58
  advice = result.advice ?? [];
83
59
  adviceGlobal = result.global ?? null;
60
+ evolutionCandidates = Array.isArray(result.candidates) ? result.candidates : [];
61
+ adviceDetails = result.adviceDetails !== null && typeof result.adviceDetails === 'object' ? result.adviceDetails : {};
84
62
  }
85
63
  } catch { /* 统计/建议是增量展示;失败不影响 /list 主响应 */ }
86
- return json(res, 200, { ok: true, profiles: listClean(deps.store, stats), costSummary, advice, adviceGlobal });
64
+ // 冷启动状态(apply/防退化可用性 + 原因清单):只读暴露,UI 展示归建议面板。
65
+ const evolutionStatus = typeof deps.evolution?.evolutionStatus === 'function' ? deps.evolution.evolutionStatus() : null;
66
+ return json(res, 200, { ok: true, profiles: listClean(deps.store, stats), costSummary, advice, adviceGlobal, evolutionCandidates, adviceDetails, evolutionStatus });
87
67
  }
88
68
 
89
69
  // 轻量摘要:enabled/advice 开关/模型目录/预设名册/审计分级。
@@ -98,6 +78,8 @@ async function handleSummary(deps, res) {
98
78
  audit: deps.getAudit(),
99
79
  escapeEnabled: deps.getEscapeEnabled(),
100
80
  escapePresets: deps.escape.list(),
81
+ candidateMode: deps.store.loadCandidateMode(),
82
+ candidateTtlH: deps.store.loadCandidateTtlH(),
101
83
  });
102
84
  }
103
85
 
@@ -267,6 +249,12 @@ async function handleAdd(deps, req, res) {
267
249
  const detail = warnings.map((w) => `${w.field}:${w.reason}`).join(';');
268
250
  return json(res, 400, { ok: false, error: `写入被拒绝:${detail}` });
269
251
  }
252
+ // description 落库契约(仅当请求显式携带 description 键:新建/编辑严格,未传
253
+ // 保留存量值不校验——存量宽容)。
254
+ if (Object.hasOwn(profile, 'description')) {
255
+ try { assertProfileDescription(profile.description); }
256
+ catch (error) { return json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); }
257
+ }
270
258
  const hadToolFilter = profile.toolFilter !== undefined;
271
259
  // tokenTier 单独用 raw-body 守卫(sanitize 恒回填 balanced,进 merge 会破坏
272
260
  // 「未传→保留 existing」语义);strict 下非法值已在上面 400 拒绝。
@@ -293,7 +281,7 @@ async function handleAdd(deps, req, res) {
293
281
  }
294
282
  if (hadTokenTier) merged.tokenTier = clean.tokenTier;
295
283
  if (merged.enabled !== undefined) merged.enabled = merged.enabled === false ? false : true;
296
- deps.store.profiles.set(merged.id, { ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
284
+ deps.store.saveProfile({ ...merged, ...(isBuiltin ? { builtin: true } : {}), persisted: true });
297
285
  deps.store.deletedBuiltins.delete(merged.id);
298
286
  return persistOk(res, { id: merged.id }, deps.store.persistProfiles());
299
287
  }
@@ -386,6 +374,12 @@ async function routeRequest(deps, req, res, url, sub) {
386
374
  if (req.method === 'POST' && sub === '/draft/apply') return handleDraftApply(deps, req, res);
387
375
  if (req.method === 'POST' && sub === '/draft/remove') return handleDraftRemove(deps, req, res);
388
376
  if (req.method === 'POST' && sub === '/draft/preview') { const body = await readBody(req); return json(res, 200, { ok: true, ...(await deps.previewDraft(body)) }); }
377
+ if (req.method === 'GET' && sub === '/evolution/assets') return handleEvolutionAssets(deps, res);
378
+ if (req.method === 'POST' && sub === '/evolution/apply') return handleEvolutionApply(deps, req, res);
379
+ if (req.method === 'POST' && sub === '/evolution/rollback') return handleEvolutionRollback(deps, req, res);
380
+ if (req.method === 'POST' && sub === '/evolution/regenerate') return handleEvolutionRegenerate(deps, req, res);
381
+ if (req.method === 'POST' && sub === '/advice/save-profile') return handleAdviceSaveProfile(deps, req, res);
382
+ if (req.method === 'POST' && sub === '/settings/candidate') return handleSettingsCandidate(deps, req, res);
389
383
  if (req.method === 'POST' && sub === '/options/refresh') return handleRefresh(deps, res);
390
384
  if (req.method === 'POST' && sub === '/set-enabled') return handleSetEnabled(deps, req, res);
391
385
  if (req.method === 'POST' && sub === '/set-evolution-advice') return handleSetEvolutionAdvice(deps, req, res);
@@ -400,8 +394,8 @@ async function routeRequest(deps, req, res, url, sub) {
400
394
  json(res, 404, { ok: false, error: `未知路由 ${sub}` });
401
395
  }
402
396
 
403
- export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, logger }) {
404
- const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, logger };
397
+ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger }) {
398
+ const deps = { store, getEnabled, setEnabled, syncTool, catalog, ledger, backgroundLedger, draftsStore, applyDraft, getAudit, getEvolutionAdvice, setEvolutionAdvice, getEscapeEnabled, setEscapeEnabled, escape, refreshAdvice, summariesFile, dispatchFile, adviceWhitelist, previewDraft, reminderStore, adviceSource, evolution, logger };
405
399
  const handler = async (req, res) => {
406
400
  const remote = req.socket?.remoteAddress;
407
401
  if (!LOOPBACKS.has(remote)) return json(res, 403, { ok: false, error: '仅限本机访问' });
@@ -419,7 +413,9 @@ export function createHttpRoutes({ webServer, store, getEnabled, setEnabled, syn
419
413
  return json(res, 403, { ok: false, error: `CSRF 防护拒绝:${violation.reason}` });
420
414
  }
421
415
  }
422
- return routeRequest(deps, req, res, url, sub);
416
+ // 显式 await:async 路由的拒绝必须落入下方 catch(未 await 时 reject 会绕过
417
+ // catch 直接传播给 webServer,fail-loud 变成未捕获拒绝)。
418
+ return await routeRequest(deps, req, res, url, sub);
423
419
  } catch (error) {
424
420
  // 通用 500 不回显内部错误信息(防泄漏),详情只进宿主日志。
425
421
  deps.logger.error('[dsh-subagent-profile] settings route error:', error instanceof Error ? (error.stack ?? error.message) : String(error));
@@ -0,0 +1,77 @@
1
+ // lib/core/model-policy.mjs — 官方子代理模型选择投影(subagentModelSelectionPolicy)
2
+ // 的读取与生效路由断言。与官方工具链同源同 key(key 字符串照抄官方投影定义),
3
+ // 无 @deepseek-ai 依赖,可被 bare-import 单测直接 import。
4
+ //
5
+ // 读取语义(与官方一致):
6
+ // * sessionProjections 服务缺失 / stateOf 不可用 / 投影值 undefined 或 null
7
+ // (key 已注册但本会话未固化策略,官方视同未配置)→ { configured:false },
8
+ // 跳过、不抛错——插件行为与 0.4.0 完全一致。
9
+ // * 投影值为数组 → 形状校验(照官方 assertAllowedModelRoutes:数组、每项
10
+ // provider/model 为非空字符串、无重复路由、至少一条)→ 畸形 fail-loud,
11
+ // 官方格式变更不得静默失效。
12
+ //
13
+ // 生效路由判定:判定输入是 per-call > profile > parent 解析链完成后的最终
14
+ // provider/model(含继承带出的模型),与 allowedModels 精确成对匹配。
15
+
16
+ const PROJECTION_SERVICE = 'sessionProjections';
17
+ const POLICY_KEY = 'subagentModelSelectionPolicy';
18
+
19
+ // 三层中文错误文案(定稿):第一层说清越界事实与白名单,第二层说明与官方通道
20
+ // 同策略,第三层给出行动路径。缺省侧(parent 未暴露路由)以 '(parent)' 占位。
21
+ export function officialModelRejectMessage(provider, model, allowedModels) {
22
+ const shown = (value) => (typeof value === 'string' && value.length > 0 ? value : '(parent)');
23
+ const allowed = allowedModels.map((route) => `${route.provider}/${route.model}`).join('、');
24
+ return [
25
+ `dispatch: 模型 ${shown(provider)}/${shown(model)} 不在本会话允许的子代理模型白名单内(允许:${allowed})。`,
26
+ '官方在其工具链执行同一策略,本插件同步执行,以保证两通道一致。',
27
+ '请改用允许的模型/方案,或在官方设置更新模型选择(本会话策略已固定,新会话生效)。',
28
+ ].join('\n');
29
+ }
30
+
31
+ // 形状校验:照官方 assertAllowedModelRoutes 语义(非空 provider/model 字符串、
32
+ // 无重复路由),另按官方 stateSchema 的 min(1) 拒绝空数组。畸形即 fail-loud。
33
+ function validateRoutes(routes) {
34
+ if (!Array.isArray(routes) || routes.length === 0) {
35
+ throw new Error('dispatch: 官方子代理模型选择投影形状不符(须为非空路由数组)');
36
+ }
37
+ const seen = new Set();
38
+ for (const candidate of routes) {
39
+ if (
40
+ candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)
41
+ || typeof candidate.provider !== 'string' || candidate.provider.length === 0
42
+ || typeof candidate.model !== 'string' || candidate.model.length === 0
43
+ ) {
44
+ throw new Error('dispatch: 官方子代理模型选择投影形状不符(每项须为 provider/model 非空字符串)');
45
+ }
46
+ const key = `${candidate.provider}\0${candidate.model}`;
47
+ if (seen.has(key)) {
48
+ throw new Error(`dispatch: 官方子代理模型选择投影形状不符(路由 "${candidate.provider}/${candidate.model}" 重复)`);
49
+ }
50
+ seen.add(key);
51
+ }
52
+ }
53
+
54
+ // 读取官方模型选择投影。任何读取失败路径(ctx / 服务 / stateOf 缺失)与未固化
55
+ // 状态(undefined / null)统一回退 { configured:false }(跳过语义);投影存在则
56
+ // 校验形状后返回路由表副本(返回值不得被调用方改写共享)。
57
+ export function readModelSelectionPolicy(ctx, session) {
58
+ const projections = typeof ctx?.get === 'function' ? ctx.get(PROJECTION_SERVICE) : undefined;
59
+ const raw = projections !== null && typeof projections?.stateOf === 'function'
60
+ ? projections.stateOf(session, POLICY_KEY)
61
+ : undefined;
62
+ if (raw === undefined || raw === null) return { configured: false, enabled: false, allowedModels: [] };
63
+ validateRoutes(raw);
64
+ return {
65
+ configured: true,
66
+ enabled: true,
67
+ allowedModels: raw.map((route) => ({ provider: route.provider, model: route.model })),
68
+ };
69
+ }
70
+
71
+ // 生效路由断言:policy 未配置恒通过;否则 provider/model 与 allowedModels 精确
72
+ // 成对匹配,不匹配 throw 三层中文错误(留痕与审计由调用方闸层负责)。
73
+ export function assertOfficialModelRoute(policy, provider, model) {
74
+ if (policy === undefined || policy.configured !== true) return;
75
+ const allowed = policy.allowedModels.some((route) => route.provider === provider && route.model === model);
76
+ if (!allowed) throw new Error(officialModelRejectMessage(provider, model, policy.allowedModels));
77
+ }
@@ -2,7 +2,7 @@
2
2
  // 仅 node 内置,无 @deepseek-ai 依赖)。
3
3
  //
4
4
  // 宿主启动时插件把 bundled `presets/` 树同步进 DSH agent-presets 发现根
5
- // (~/.dsh/.agent-presets),使「编排者模式」无需手动复制就出现在新建会话
5
+ // (~/.dsh/.agent-presets),使「编排者模式 V2」无需手动复制就出现在新建会话
6
6
  // 选择器里——与官方 dsh-liangshen bundle 相同的自安装模式。同步按目录、
7
7
  // 经哈希门控:per-target sidecar(.dsh-sync.json)记录 bundled 内容哈希与派生树
8
8
  // 快照哈希,下次运行跳过未变化的树、跳过(绝不覆盖)用户改过的树;两侧都变时
@@ -230,9 +230,26 @@ export function syncOnePreset(sourceDir, targetDir) {
230
230
  return 'synced';
231
231
  }
232
232
 
233
+ // 旧预设收编归档(V2 取代):bundled 已不再提供 `orchestrator`(由
234
+ // `orchestrator-v2` 收编取代),发现根里若残留旧目录(旧版本自装、用户手工创建
235
+ // 或恢复的),同步时改名归档为 orchestrator.removed-<stamp>——与卸载清理同一样式:
236
+ // 只改名不删除(用户数据保留),幂等(归档后旧名即不存在,下次同步无操作)、
237
+ // fail-soft(改名失败不抛不阻断,下次启动重试)。归档名含点号,不满足官方
238
+ // PRESET_ID(^[a-z0-9][a-z0-9-]*$),不会出现在预设选择器。
239
+ function archiveLegacyOrchestrator(targetRoot) {
240
+ const legacy = join(targetRoot, 'orchestrator');
241
+ if (!existsSync(legacy)) return false;
242
+ try {
243
+ renameSync(legacy, join(targetRoot, `orchestrator.removed-${Date.now()}`));
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
233
250
  // 把 `presets/` 下每个 preset 目录同步进目标发现根。
234
251
  export function syncBundledPresets(targetRoot) {
235
- const result = { synced: [], current: [], userModified: [], failed: [] };
252
+ const result = { synced: [], current: [], userModified: [], failed: [], archived: [] };
236
253
  const sourceRoot = bundledPresetsRoot();
237
254
  mkdirSync(targetRoot, { recursive: true });
238
255
  if (existsSync(sourceRoot)) {
@@ -250,5 +267,10 @@ export function syncBundledPresets(targetRoot) {
250
267
  }
251
268
  }
252
269
  }
270
+ // 旧名收编归档:仅当 bundled 侧确无 orchestrator 目录时处理(防御:未来若仓库
271
+ // 回补同名 bundled,以 bundled 为准、不误归档其派生树)。
272
+ if (!existsSync(join(sourceRoot, 'orchestrator')) && archiveLegacyOrchestrator(targetRoot)) {
273
+ result.archived.push('orchestrator');
274
+ }
253
275
  return result;
254
276
  }
@@ -125,6 +125,23 @@ export function profileStatsFromSummaries(store, summaries) {
125
125
  return out;
126
126
  }
127
127
 
128
+ // dispatch:profiles section 文本(模型可见的轻量目录,瘦身):行格式
129
+ // `- id: "描述" (tier: X)`——成本/成功率等统计只进 profiles_query 与人类面板,不进
130
+ // 静态段。description 为空显示 (无描述)(存量宽容渲染);note 一行查表式并指向
131
+ // profiles_query 下钻。gate 由调用方(index.mjs 的 profileSectionGate)传入。
132
+ export function profileSectionText(store, gate, context) {
133
+ if (!gate(context)) return '';
134
+ const rows = profileDirectoryRows(store);
135
+ if (rows.length === 0) return '';
136
+ const lines = rows.map((p) => {
137
+ const desc = typeof p.description === 'string' && p.description.length > 0 ? `"${p.description}"` : '(无描述)';
138
+ const tier = typeof p.tokenTier === 'string' && p.tokenTier !== '' ? ` (tier: ${p.tokenTier})` : '';
139
+ return `- ${p.id}: ${desc}${tier}`;
140
+ });
141
+ const note = '按任务复杂度匹配方案能力(查资料/汇总→cheap 或 flash;写/改代码→swap-standard;深度推理→premium;1-2 步可自查的小事直接做别派;可用方案与明细用 profiles_query 查询)';
142
+ return `Available dispatch profiles (dispatch.profile):\n${lines.join('\n')}\n${note}`;
143
+ }
144
+
128
145
  // 用 stats map 为目录行补 avgCost/successRate(F 数据层 join)。stats 为
129
146
  // profileStatsFromSummaries 的返回值;行上已有 avgCost/successRate 时优先保留
130
147
  // 显式值(store 侧/测试注入),stats 只补缺失。
@@ -30,6 +30,8 @@ import { computeEffectiveAllow } from './intersection.mjs';
30
30
  import { resolveWhitelist } from './whitelist.mjs';
31
31
  import { assertCostGuard } from './cost-guard.mjs';
32
32
  import { DELEGATION_CONTEXT, buildDispatchMeta } from './delegation.mjs';
33
+ import { readSessionEvents } from './session-read.mjs';
34
+ import { applyOfficialModelProviderGate } from './dispatch-gates.mjs';
33
35
 
34
36
  // --- start 预检段(从 start 拆出)-------------------------------------------------
35
37
 
@@ -63,6 +65,9 @@ async function runStartPreflight(request, deps) {
63
65
  // 权威 cost guard(运行时推导;硬上限始终生效,llm 能力核验由 allowFailOpen 门控;
64
66
  // llm 目录读取走共享 catalog 快照)。
65
67
  await assertCostGuard(parent, profile, deps.store.getAllowFailOpen(), deps.logger, deps.catalog);
68
+ // 官方模型选择投影读取(服务/投影缺失或未固化 → configured:false 跳过);生效
69
+ // 路由判定在 agentOptions 解析完成后——provider 渠道与 dispatch 预检同一白名单。
70
+ const policy = deps.readModelSelectionPolicy?.(parent.ctx, parent.session);
66
71
  // 委派深度:官方助手——先断言上限值,再解析子深度(父底 + 1)并执行上限。
67
72
  assertSubagentMaxDepth(profile.maxDepth);
68
73
  const childDepth = resolveChildDepth(parent, profile.maxDepth);
@@ -72,6 +77,7 @@ async function runStartPreflight(request, deps) {
72
77
  const swapPreset = typeof profile.preset === 'string' && profile.preset !== 'inherit' && profile.preset !== parentComposed;
73
78
  const meta = buildProviderMeta(parent, profile, swapPreset, childDepth);
74
79
  const agentOptions = buildProviderAgentOptions(parent, profile, childDepth);
80
+ applyOfficialModelProviderGate({ evoLedger: deps.evoLedger, sessionId: parent.session?.header?.id }, policy, agentOptions);
75
81
  return { parent, profile, delegated, childId, swapPreset, meta, agentOptions };
76
82
  }
77
83
 
@@ -195,7 +201,7 @@ function restrictChildTools(childCtx, parent, profile) {
195
201
  // 已取消时 result 闭包跳过 followup。
196
202
  function wireChildLifecycle(handle, request, childId, swapPreset, profile, logger) {
197
203
  const child = handle.agent;
198
- const boundary = child.session.events.length;
204
+ const boundary = readSessionEvents(child.session).length;
199
205
  const flags = { cancelled: false };
200
206
  const onAbort = () => {
201
207
  flags.cancelled = true;
@@ -233,8 +239,8 @@ function wireChildLifecycle(handle, request, childId, swapPreset, profile, logge
233
239
  };
234
240
  }
235
241
 
236
- export function createProfileProvider({ subagents, store, getEnabled, logger, catalog, getEscapeSet }) {
237
- const deps = { store, getEnabled, logger, catalog, getEscapeSet };
242
+ export function createProfileProvider({ subagents, store, getEnabled, logger, catalog, getEscapeSet, readModelSelectionPolicy, evoLedger }) {
243
+ const deps = { store, getEnabled, logger, catalog, getEscapeSet, readModelSelectionPolicy, evoLedger };
238
244
  return subagents.registerProvider({
239
245
  name: 'profile',
240
246
  capabilities: { outputSchema: false, depthLimit: true, toolFilter: true, persona: true },