dsh-prompt-customizer 0.2.7 → 0.3.1

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/lib/index.js CHANGED
@@ -1,40 +1,291 @@
1
1
  /**
2
- * dsh-prompt-customizer — host half.
2
+ * dsh-prompt-customizer — 宿主端。
3
3
  *
4
- * - Registers the `prompt-customizer` settings namespace (live-applied).
5
- * - Hooks the `system-prompt/assemble` waterfall to block / replace / inject
6
- * prompt sections and to hide tools from the model catalog by name. Only the
7
- * model-facing list is affected; tools and routes keep working.
8
- * - Serves `GET /api/prompt-customizer/inventory` returning the effective
9
- * section and tool lists (for the `standard` agent when a roster exists),
10
- * each marked with whether it is currently blocked / hidden.
4
+ * - 配置存储在插件自有文件 `~/.dsh/prompt-customizer/config.yaml`(可用组合
5
+ * 入口 `dataDir` 覆盖目录),不再占用主文档 settings.yaml。首次启动时若
6
+ * 新文件不存在而主文档里有旧 `prompt-customizer:` 段,自动迁移(主文档
7
+ * 原样保留,可随时回滚旧版),此后本插件不再注册设置命名空间——主文档
8
+ * 的设置页也不会再显示本段落。
9
+ * - 挂钩 `system-prompt/assemble` 瀑布流,按名称屏蔽 / 替换 / 注入提示词段,
10
+ * 并从模型目录中隐藏工具。只影响面向模型的目录;工具与路由照常工作。
11
+ * - 提供 `GET /api/prompt-customizer/inventory`(段/工具清单,`?scope=` 可切
12
+ * agent 预设;`scopeResolved` 标记 scope 是否成功挂载)、
13
+ * `GET /api/prompt-customizer/config` 与 `POST /config/set|/config/unset`
14
+ * (单字段读写)、`POST /config/apply`(批量保存,UI 统一保存按钮的写盘通道)。
15
+ *
16
+ * 定制按作用域生效:顶层字段是全局默认;`overrides` 按 agent 预设 id 提供
17
+ * 字段级覆盖(非空字段整体接管,空缺回落全局)。注入段支持 `phase`
18
+ * (always/bootstrap/active),工具过滤支持未晋级阶段的 bootstrap 目录与
19
+ * 压缩后未晋级阶段的 compaction 目录(三态:compaction > bootstrap > 静态)——
20
+ * 晋级由 durable 会话事件推导(compaction 复位,subagent 恒已晋级)。
21
+ *
22
+ * 客户端对 bootstrap / compaction / active 三套名义装配各拉取一次
23
+ * `/preview?phase=…`,三个阶段部分**恒定全部显示**(预设没有某个阶段时,
24
+ * 该部分只是空的)—— 不同 agent 预设的「agent 周期」不同(standard 三态同形,
25
+ * minimal 一个形态,router / warmup 两个,anchored 家族三个),但那是各阶段
26
+ * 内容是否相同的问题,不构成把阶段藏起来的理由。
27
+ *
28
+ * 另一面:若该 scope 有以 `complete: true` 注册的段(persona 整段接管),宿主
29
+ * 会在装配瀑布流**之后**把 sections 强制还原成那一条段(见 dsh-system-prompt
30
+ * 的 assemble 契约),本插件的段级屏蔽 / 替换 / 注入 / 排序对其完全不生效
31
+ * (工具过滤不受影响)。预览过滤器会探测并把段名作为 `takenOverBy` 透出,
32
+ * 让提示词 / 预览两个 Tab 就这件事说同一句话,而不是各说各话。
11
33
  */
12
34
 
13
- import { settingsNamespace } from '@deepseek-ai/dsh-settings'
35
+ import fs from 'node:fs'
36
+ import os from 'node:os'
37
+ import path from 'node:path'
14
38
  import { Config, NS } from './schema.js'
39
+ import { mergeConfig, filterInjectByPhase, pickToolsFilter, pickSectionsForStatus, applyToolFilter } from './effective.js'
40
+ import { createPromotion, presetOfSession } from './promotion.js'
41
+ import { createConfigStore, readLegacySection } from './store.js'
42
+ import { createCatalog } from './catalog.js'
15
43
 
16
44
  export const name = 'prompt-customizer'
45
+ // settings 仍被注入:只读 documentPath 做一次性旧版迁移。cordis 对未在
46
+ // inject 里声明的服务调用 ctx.get 会抛错,漏声明会让整个插件加载失败。
17
47
  export const inject = ['settings', 'systemPrompt', 'tools']
18
48
 
49
+ /** harness home;ponytail: DSH_HOME 为推测的官方环境变量,若宿主日后提供
50
+ * 正式的 home 解析 API 应换过去。回退 `~/.dsh`。 */
51
+ function dshHome() {
52
+ return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
53
+ }
54
+
55
+ /** 收集并解析一个请求体(JSON),超过上限直接拒绝——路由是本进程私有的,上限只是防御。 */
56
+ function readJsonBody(req, limit = 2 * 1024 * 1024) {
57
+ return new Promise((resolve, reject) => {
58
+ let size = 0
59
+ const chunks = []
60
+ req.on('data', (chunk) => {
61
+ size += chunk.length
62
+ if (size > limit) {
63
+ reject(new Error('request body too large'))
64
+ req.destroy()
65
+ return
66
+ }
67
+ chunks.push(chunk)
68
+ })
69
+ req.on('end', () => {
70
+ try {
71
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
72
+ } catch (error) {
73
+ reject(new Error(`invalid JSON body: ${error.message}`))
74
+ }
75
+ })
76
+ req.on('error', reject)
77
+ })
78
+ }
79
+
80
+ /**
81
+ * 一次性清理遗留的 include(白名单)设置:过滤只剩 exclude 黑名单,服务端不再
82
+ * 读取 include,界面也不再写它。
83
+ *
84
+ * 只要 include 键存在就剥掉并落盘(空数组同样是无意义字段);但只有真去掉了
85
+ * 有内容的名单才记一条日志(列出去处与名字)—— 不为一串空数组惊动用户。
86
+ */
87
+ function stripLegacyInclude(store, log) {
88
+ const raw = store.raw()
89
+ if (raw === null || typeof raw !== 'object') return
90
+ const stats = { stripped: 0, reported: [] }
91
+ const walk = (node, where) => {
92
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) return
93
+ if (Array.isArray(node.include)) {
94
+ stats.stripped += 1
95
+ if (node.include.length > 0) stats.reported.push(`${where}: [${node.include.join(', ')}]`)
96
+ delete node.include
97
+ }
98
+ for (const phase of ['bootstrap', 'compaction']) walk(node[phase], `${where}.${phase}`)
99
+ }
100
+ walk(raw.tools, 'tools')
101
+ if (raw.overrides !== null && typeof raw.overrides === 'object') {
102
+ for (const id of Object.keys(raw.overrides)) walk(raw.overrides[id]?.tools, `overrides.${id}.tools`)
103
+ }
104
+ if (Array.isArray(raw.presets)) {
105
+ for (const preset of raw.presets) walk(preset?.data?.tools, `presets.${preset?.name}.data.tools`)
106
+ }
107
+ if (stats.stripped === 0) return
108
+ try {
109
+ store.writeSection(raw)
110
+ if (stats.reported.length > 0) {
111
+ log(`prompt-customizer: 白名单(include)语义已移除,已从配置里清掉 ${stats.reported.length} 处有内容的名单 —— ${stats.reported.join(';')}。这些阶段现在不设限制;要重新限制就去工具 Tab 逐项取消勾选。`)
112
+ }
113
+ } catch (error) {
114
+ try { log(`prompt-customizer: 遗留 include 清理失败(不影响运行,该字段已被忽略):${String(error && error.message ? error.message : error)}`) } catch { /* logger 缺席 */ }
115
+ }
116
+ }
117
+
19
118
  export function apply(ctx, entry = {}) {
20
- // 1) settings namespace: base = composition entry, live-applied.
21
- const scope = ctx.settings.register(settingsNamespace(NS), Config, {
22
- base: entry,
23
- applies: 'live',
119
+ // 1) 插件自有配置文件(不再注册 settings 命名空间)。
120
+ const dataDir = typeof entry.dataDir === 'string' && entry.dataDir.length > 0
121
+ ? entry.dataDir
122
+ : path.join(dshHome(), NS)
123
+ const configFile = path.join(dataDir, 'config.yaml')
124
+ const store = createConfigStore({
125
+ file: configFile,
126
+ schema: Config,
127
+ warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
128
+ })
129
+ const read = () => store.readResolved()
130
+ // 「本系统全部提示词 / 全部工具」的累积登记表:清单与预览每次看到什么就并进
131
+ // 什么(同名后见到者赢),所以池子不随编辑目标切换、只增不减。派生缓存,
132
+ // 删掉随浏览重新长出来。
133
+ const catalog = createCatalog({
134
+ dir: dataDir,
135
+ warn: (message) => { try { ctx.logger?.warn?.(message) } catch { /* logger 缺席 */ } },
136
+ })
137
+
138
+ // 一次性迁移:新文件不存在而主文档里有旧段 → 原样复制过来。主文档不动,
139
+ // 旧版随时可回滚;迁移后新文件是唯一权威。
140
+ if (!fs.existsSync(configFile)) {
141
+ const master = ctx.get('settings')?.documentPath
142
+ const legacy = readLegacySection(master, NS)
143
+ if (legacy) {
144
+ store.writeSection(legacy)
145
+ try { ctx.logger?.info?.(`prompt-customizer: 已把旧版配置从 ${master} 迁移到 ${configFile}`) } catch { /* logger 缺席 */ }
146
+ }
147
+ }
148
+
149
+ // 一次性清理:include(白名单)语义已从读取与写入两侧彻底移除。遗留的
150
+ // include 既不再生效、界面也不再表达,留着只会让配置堆积无意义字段 ——
151
+ // 启动时剥掉并记一条日志(逐处列出被去掉的名字),不静默改用户文件。
152
+ stripLegacyInclude(store, (message) => { try { ctx.logger?.info?.(message) } catch { /* logger 缺席 */ } })
153
+
154
+ // 2) 晋级 tracker:增量维护每会话的 bootstrap/active 阶段(durable 事件
155
+ // 推导,compaction 复位,subagent 恒已晋级)。
156
+ const promotion = createPromotion()
157
+ ctx.on('session/event', (session, event) => {
158
+ try { promotion.observe(session, event) } catch { /* tracker 故障绝不影响事件流 */ }
24
159
  })
25
- const read = () => scope.get() ?? {}
26
160
 
27
- // 2) assemble waterfall: apply filter/replace/inject live, choosing the
28
- // per-agent overlay when the assembling context carries an agent scope.
161
+ // 单次降级告警:过滤逻辑出错时退回未加工的装配,绝不拖垮会话。
162
+ let warned = false
163
+ const warnOnce = (message) => {
164
+ if (warned) return
165
+ warned = true
166
+ try { ctx.logger?.warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
167
+ }
168
+
169
+ // 3) 装配瀑布流:实时应用字段级 override、屏蔽 / 替换 / 注入 / 阶段化工具过滤。
29
170
  ctx.on('system-prompt/assemble', async (assembly, context, next) => {
171
+ // 下游错误原样传播;只有本过滤器自身的逻辑被兜底。
30
172
  const assembled = await next()
31
- const cfg = mergeForAgent(read(), scopeIdOf(context))
32
- return { ...assembled, ...applyFilter(assembled, cfg) }
173
+ try {
174
+ const raw = read()
175
+ // 生效配置:显式预览提示 > 会话所属 agent 预设(字段级覆盖)> 全局默认。
176
+ const id = typeof context?.promptCustomizerPreset === 'string'
177
+ ? context.promptCustomizerPreset
178
+ : presetOfSession(context?.agent?.session)
179
+ const cfg = mergeConfig(raw, raw.overrides?.[id])
180
+ // 预览 hint:显式 promptCustomizerPhase 时以它替代由会话事件推导的
181
+ // 实际阶段,让同一份配置能渲染 bootstrap / compaction / active 三种
182
+ // 阶段视图。boundary 语义与 promotion.status 一致(-1 = 从未压缩)。
183
+ const phaseHint = typeof context?.promptCustomizerPhase === 'string'
184
+ ? context.promptCustomizerPhase
185
+ : undefined
186
+ const status = phaseHint === 'bootstrap' ? { promoted: false, boundary: -1 }
187
+ : phaseHint === 'compaction' ? { promoted: false, boundary: 1 }
188
+ : phaseHint === 'active' ? { promoted: true, boundary: -1 }
189
+ : promotion.status(context?.agent)
190
+ const denied = new Set([...(cfg.sections ?? []), ...pickSectionsForStatus(cfg, status)])
191
+ const replace = cfg.replace ?? {}
192
+ // inject 按当前阶段模式筛选(bootstrap/active/compact 三态注入互相独立)。
193
+ const injectList = filterInjectByPhase(cfg.inject, status)
194
+ const te = pickToolsFilter(cfg.tools, status)
195
+
196
+ // 预览专用的预过滤视图(提示词 / 工具 / 预览三个 Tab 的统一数据源):
197
+ // 记录进入本过滤规则的段 / 工具原文,并附上该阶段专属的屏蔽 / 隐藏标记,
198
+ // 让 UI 能正确回显「每阶段独立名单」和工具的逐项隐藏(隐藏后仍在列表)。
199
+ // 运行时装配不带该 hint,零额外开销。
200
+ const baseView = context?.promptCustomizerBase === true
201
+ ? {
202
+ sections: Array.isArray(assembled.sections) ? assembled.sections.map((section) => ({
203
+ name: section.name,
204
+ text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
205
+ blocked: denied.has(section.name),
206
+ replaced: Object.hasOwn(replace, section.name),
207
+ })) : [],
208
+ tools: Array.isArray(assembled.tools) ? assembled.tools.map((tool) => ({
209
+ name: typeof tool.name === 'string' ? tool.name : String(tool.name),
210
+ description: typeof tool.description === 'string' ? tool.description : '',
211
+ hidden: (te.exclude ?? []).includes(tool.name),
212
+ })) : [],
213
+ }
214
+ : undefined
215
+
216
+ // 本插件过滤前的原始输入(段 / 工具)—— 预过滤视图与 post 视图同源,
217
+ // 让提示词 / 工具 / 预览三个 Tab 与运行时装配所见一致。
218
+ const baseSections = Array.isArray(assembled.sections) ? assembled.sections : []
219
+ const baseTools = Array.isArray(assembled.tools) ? assembled.tools : []
220
+
221
+ let sections = baseSections
222
+ .filter((section) => !denied.has(section.name))
223
+ .map((section, i) =>
224
+ Object.hasOwn(replace, section.name) ? { ...section, order: i, text: replace[section.name] } : { ...section, order: i },
225
+ )
226
+ for (const item of injectList) {
227
+ // 被屏蔽的段直接跳过 —— 上面已经把它们过滤掉了。
228
+ if (denied.has(item.name)) continue
229
+ const index = sections.findIndex((section) => section.name === item.name)
230
+ if (index >= 0) {
231
+ // 已存在的段:覆盖 order 来控制拼接顺序。文本按「本阶段注入文本 → 该
232
+ // 段当前文本」取(后者可能已被全局 replace 改过)—— injectList 已经按
233
+ // 阶段筛过,所以带 phase 的文本只作用在本阶段,三个阶段各改各的;全局
234
+ // replace 字典是跨阶段的(旧语义),只在没有阶段文本时生效。
235
+ sections[index] = {
236
+ ...sections[index],
237
+ text: item.text ? item.text : sections[index].text,
238
+ order: item.order,
239
+ }
240
+ } else if (item.custom === true || Object.hasOwn(replace, item.name) || item.text) {
241
+ // 装配里没有这个名字:只有「本插件自撰的 custom 段」「被替换的段」
242
+ // 「自带文本的注入段」才允许新建条目。只带 order 的空文本系统名必须
243
+ // 跳过 —— 它通常来自快照里混进的别的预设独有段名(并集池会带回来),
244
+ // 新建就变成一条空白幽灵段,实打实进最终提示词。
245
+ sections.push({ name: item.name, order: item.order, text: item.text || replace[item.name] })
246
+ }
247
+ }
248
+ // 虚拟 order 排序:order 缺省按 0,稳定排序保持系统段原始相对顺序;
249
+ // 注入段携带各自的 order(120+ 落在系统段之后,或被重排到任意断点),
250
+ // 以 order 升序决定注入顺序 —— 与「每阶段独立名单」配合即每阶段独立序。
251
+ sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
252
+
253
+ const tools = appendAddedTools(ctx, context?.scope, applyToolFilter(baseTools, te), te.add, te.exclude)
254
+
255
+ const result = { ...assembled, sections, tools }
256
+ if (baseView) {
257
+ result.promptCustomizerBaseView = {
258
+ sections: baseSections.map((section) => ({
259
+ name: section.name,
260
+ text: typeof section.text === 'function' ? '<动态生成>' : String(section.text ?? ''),
261
+ blocked: denied.has(section.name),
262
+ replaced: Object.hasOwn(replace, section.name),
263
+ })),
264
+ tools: baseTools.map((tool) => ({
265
+ name: typeof tool.name === 'string' ? tool.name : String(tool.name),
266
+ description: typeof tool.description === 'string' ? tool.description : '',
267
+ hidden: (te.exclude ?? []).includes(tool.name),
268
+ })),
269
+ }
270
+ // 预览才探测(运行时装配零额外开销):有 complete 段时宿主要在水流
271
+ // 之后把 sections 整段还原,本插件的段级定制不会进最终提示词。
272
+ const takenOver = detectCompletePrompt(ctx, context?.scope)
273
+ if (takenOver !== undefined) result.promptCustomizerTakenOver = takenOver
274
+ // 本插件实际产出的段名清单:下游还有别的手段会丢弃段(实测 liangshen
275
+ // 引导期由预设自身规则把 20 段砍到 1 段,注册表里并没有 complete 段),
276
+ // 所以 buildPreview 还要拿这份与最终装配比对,做机制无关的兜底探测。
277
+ result.promptCustomizerEmitted = sections.map((section) => String(section.name))
278
+ }
279
+ return result
280
+ } catch (error) {
281
+ warnOnce(`prompt-customizer: 过滤器异常,本次装配回退为未加工结果:${String((error && error.message) || error)}`)
282
+ return assembled
283
+ }
33
284
  })
34
285
 
35
- // 3) inventory route on the shared webserver (present in every web profile).
36
- // Use ctx.get() so this stays optional — accessing ctx.webServer without
37
- // declaring it in `inject` would throw "cannot get property without inject".
286
+ // 4) 共享 webserver 上的清单路由(web profile 里必有)。
287
+ // 用 ctx.get() 保持可选 —— 未在 `inject` 中声明就去访问 ctx.webServer
288
+ // 会抛 "cannot get property without inject"。
38
289
  const webserver = ctx.get('webServer')
39
290
  if (webserver) {
40
291
  const dispose = webserver.register({
@@ -42,9 +293,18 @@ export function apply(ctx, entry = {}) {
42
293
  path: '/api/prompt-customizer/inventory',
43
294
  handler: async (req, res) => {
44
295
  try {
45
- const scopeKey = await previewScope(ctx, req)
46
- const cfg = mergeForAgent(read(), agentIdOf(scopeKey))
47
- writeJson(res, 200, await buildInventory(ctx, cfg, scopeKey))
296
+ // 可选 `?scope=<agentPresetId>`:清单切换到该预设的 scope 与
297
+ // 生效配置(字段级 override 后),供 UI 按目标编辑;缺省为 standard。
298
+ // 宿主共享 webserver 的 req 是原始 IncomingMessage:没有 Express 的
299
+ // query 属性,必须自行解析 URL 参数。
300
+ const id = queryOf(req).get('scope') ?? undefined
301
+ // scope 挂载失败(如常驻挂载超时)会回退展示 standard / 全局层:
302
+ // scopeResolved 标记「请求的目标是否解析出了自己的 scope」,false 时
303
+ // UI 警示「清单并非该预设的原生目录」。
304
+ const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
305
+ const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
306
+ const inventory = await buildInventory(ctx, read(), scopeKey, id, catalog)
307
+ writeJson(res, 200, { ...inventory, scopeResolved: resolved !== undefined })
48
308
  } catch (error) {
49
309
  writeJson(res, 500, {
50
310
  ok: false,
@@ -55,18 +315,20 @@ export function apply(ctx, entry = {}) {
55
315
  })
56
316
  ctx.effect(() => dispose, 'prompt-customizer: inventory route')
57
317
 
58
- // Preview route: render the FINAL assembled system prompt (after every
59
- // plugin, including our own block/replace/inject filters) so the user can
60
- // confirm no injected section from other plugins pollutes it.
61
- // Optional `?scope=<agentPresetId>` previews a specific agent preset's
62
- // assembly; omitted, it uses the standard agent (same as the inventory).
318
+ // 预览路由:渲染最终装配的系统提示词(经过所有插件的过滤,包括我们
319
+ // 自己的屏蔽/替换/注入),让用户确认没有其他插件注入的段造成污染。
320
+ // 可选 `?scope=<agentPresetId>` 预览指定 agent 预设的装配结果(省略时
321
+ // 使用 standard agent,与清单一致);可选 `?phase=<bootstrap|compaction|
322
+ // active>` 预览指定对话阶段的三态视图(省略时按晋级推导,只读路由下
323
+ // 即 active / 静态视图)。
63
324
  const disposePreview = webserver.register({
64
325
  kind: 'exact',
65
326
  path: '/api/prompt-customizer/preview',
66
327
  handler: async (req, res) => {
67
328
  try {
68
- const scopeKey = await previewScope(ctx, req)
69
- writeJson(res, 200, await buildPreview(ctx, scopeKey))
329
+ const { scopeKey, hintId, phase, scopeResolved } = await previewTarget(ctx, req)
330
+ const preview = await buildPreview(ctx, scopeKey, hintId, phase, catalog)
331
+ writeJson(res, 200, { ...preview, scopeResolved, phase })
70
332
  } catch (error) {
71
333
  writeJson(res, 500, {
72
334
  ok: false,
@@ -77,13 +339,19 @@ export function apply(ctx, entry = {}) {
77
339
  })
78
340
  ctx.effect(() => disposePreview, 'prompt-customizer: preview route')
79
341
 
80
- // Agents route: list every agent preset (for the per-agent editor UI).
81
- const disposeAgents = webserver.register({
342
+ // agent 预设枚举路由:直接读 roster 的 list()(未 memo 化,运行中新增
343
+ // 的预设立即可见),给 UI 的目标选择器与预览选择器供货。
344
+ const disposePresets = webserver.register({
82
345
  kind: 'exact',
83
- path: '/api/prompt-customizer/agents',
346
+ path: '/api/prompt-customizer/agent-presets',
84
347
  handler: async (_req, res) => {
85
348
  try {
86
- writeJson(res, 200, await listAgents(ctx))
349
+ const presets = ctx.get('agentPresets')
350
+ const list = presets ? await presets.list() : []
351
+ writeJson(res, 200, {
352
+ ok: true,
353
+ presets: list.map((p) => ({ id: p.id, name: p.name ?? p.id, description: p.description, broken: p.broken })),
354
+ })
87
355
  } catch (error) {
88
356
  writeJson(res, 500, {
89
357
  ok: false,
@@ -92,90 +360,167 @@ export function apply(ctx, entry = {}) {
92
360
  }
93
361
  },
94
362
  })
95
- ctx.effect(() => disposeAgents, 'prompt-customizer: agents route')
96
- }
97
- }
363
+ ctx.effect(() => disposePresets, 'prompt-customizer: agent-presets route')
98
364
 
99
- /**
100
- * Apply block / replace / inject / tool filtering to an assembled system
101
- * prompt. Pure and shared by the waterfall and the tests.
102
- * @returns `{ sections, tools }` to spread over the assembly.
103
- */
104
- export function applyFilter(assembled, cfg) {
105
- const denied = new Set(cfg.sections ?? [])
106
- const replace = cfg.replace ?? {}
107
- const injectList = cfg.inject ?? []
108
- const te = cfg.tools ?? {}
109
- const exclude = new Set(te.exclude ?? [])
110
- const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
111
-
112
- let sections = assembled.sections
113
- .filter((section) => !denied.has(section.name))
114
- .map((section) =>
115
- Object.hasOwn(replace, section.name) ? { ...section, text: replace[section.name] } : section,
116
- )
117
- for (const item of injectList) {
118
- const index = sections.findIndex((section) => section.name === item.name)
119
- if (index >= 0) {
120
- // Existing section: apply the order override to control splice order.
121
- // Only override text when the user actually provided replacement text.
122
- sections[index] = item.text
123
- ? { ...sections[index], text: item.text, order: item.order }
124
- : { ...sections[index], order: item.order }
125
- } else {
126
- sections.push({ name: item.name, order: item.order, text: item.text })
127
- }
128
- }
129
- sections.sort((a, b) => a.order - b.order)
130
-
131
- let tools = assembled.tools
132
- if (include) tools = tools.filter((tool) => include.has(tool.name))
133
- else if (exclude.size > 0) tools = tools.filter((tool) => !exclude.has(tool.name))
365
+ // 存为 agent 预设:整体 fork 当前编辑目标的预设目录 —— 组成文件、preset.yml、
366
+ // 伴生 .mjs、技能目录一并带走(宿主 authoring API 负责复制并解引用符号链接,
367
+ // 副本自包含),再把提交来的配置写进本插件 overrides[新名]。于是新预设 =
368
+ // 来源预设的原样组成 + 我刚改好的定制,这才叫「保存的是我修改后的样子」。
369
+ // 我们不再自己生成骨架:骨架带着 persona complete: true,宿主会在水流之后把
370
+ // sections 还原成那一条段,段级定制在它上面根本不生效。
371
+ const disposePresetsCreate = webserver.register({
372
+ kind: 'exact',
373
+ path: '/api/prompt-customizer/presets',
374
+ handler: async (req, res) => {
375
+ try {
376
+ const body = await readJsonBody(req)
377
+ const name = typeof body?.name === 'string' ? body.name.trim() : ''
378
+ if (!/^[\p{L}\p{N}_-][\p{L}\p{N} _-]{0,63}$/u.test(name)) {
379
+ writeJson(res, 400, { ok: false, error: '预设名只能包含中英文、数字、空格、下划线与连字符(≤64 字符),且不以标点开头' })
380
+ return
381
+ }
382
+ const roster = ctx.get('agentPresets')
383
+ if (roster === undefined || typeof roster.copy !== 'function') {
384
+ writeJson(res, 501, { ok: false, error: '宿主未提供 agent 预设服务(dsh-agent-presets),无法创建预设' })
385
+ return
386
+ }
387
+ if (roster.authorable === false) {
388
+ writeJson(res, 400, { ok: false, error: '当前部署没有可写的用户预设根目录,无法创建预设' })
389
+ return
390
+ }
391
+ // from = 当前编辑目标;全局目标(未选预设)回落到 roster 默认预设。
392
+ const from = typeof body?.from === 'string' && body.from.length > 0 ? body.from : String(roster.defaultId ?? 'standard')
393
+ const displayName = typeof body?.displayName === 'string' && body.displayName.trim().length > 0 ? body.displayName.trim() : name
394
+ try {
395
+ await roster.copy(from, name, displayName)
396
+ } catch (error) {
397
+ // 先 fork 后写 override:名字被占 / 非法 / 来源不存在时配置里不留空壳。
398
+ writeJson(res, copyErrorStatus(error), {
399
+ ok: false,
400
+ error: String(error && error.message ? error.message : error),
401
+ })
402
+ return
403
+ }
404
+ const config = body.config && typeof body.config === 'object' && !Array.isArray(body.config) ? body.config : {}
405
+ // 落盘 overrides[name] = 提交来的配置(净化:去掉空列表 / 空对象,避免
406
+ // 生成空壳 override);装配 / 预览 / Tab 全部走同一路径读它。
407
+ const base = { ...(store.raw() ?? {}) }
408
+ const overrides = { ...(base.overrides ?? {}) }
409
+ const clean = {}
410
+ for (const key of ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']) {
411
+ const value = config[key]
412
+ if (value === undefined || value === null) continue
413
+ const empty = Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0
414
+ if (!empty) clean[key] = value
415
+ }
416
+ overrides[name] = clean
417
+ base.overrides = overrides
418
+ store.writeSection(base)
419
+ writeJson(res, 200, { ok: true, presetId: name, from, config: read() })
420
+ } catch (error) {
421
+ writeJson(res, 500, {
422
+ ok: false,
423
+ error: String(error && error.message ? error.message : error),
424
+ })
425
+ }
426
+ },
427
+ })
428
+ ctx.effect(() => disposePresetsCreate, 'prompt-customizer: presets-create route')
134
429
 
135
- return { sections, tools }
136
- }
430
+ // 配置读写路由(插件自有 config.yaml 的前端通道):
431
+ // GET /config → { ok, config }(解析后含默认值)
432
+ // POST /config/set → body { field, value };写入并返回最新 config
433
+ // POST /config/unset → body { field };删除字段并返回最新 config
434
+ const disposeConfig = webserver.register({
435
+ kind: 'exact',
436
+ path: '/api/prompt-customizer/config',
437
+ handler: async (_req, res) => {
438
+ writeJson(res, 200, { ok: true, config: read() })
439
+ },
440
+ })
441
+ ctx.effect(() => disposeConfig, 'prompt-customizer: config route')
137
442
 
138
- /** Extract the agent-preset id from an assemble context's scope, if any. */
139
- function scopeIdOf(context) {
140
- const scope = context && context.scope
141
- if (scope && typeof scope === 'object' && typeof scope.agentPreset === 'string') return scope.agentPreset
142
- return undefined
143
- }
443
+ const withFieldWrite = (handler) => async (req, res) => {
444
+ try {
445
+ const body = await readJsonBody(req)
446
+ if (typeof body?.field !== 'string' || body.field.length === 0) {
447
+ writeJson(res, 400, { ok: false, error: 'field is required' })
448
+ return
449
+ }
450
+ handler(body)
451
+ writeJson(res, 200, { ok: true, config: read() })
452
+ } catch (error) {
453
+ writeJson(res, 400, {
454
+ ok: false,
455
+ error: String(error && error.message ? error.message : error),
456
+ })
457
+ }
458
+ }
459
+ const disposeConfigSet = webserver.register({
460
+ kind: 'exact',
461
+ path: '/api/prompt-customizer/config/set',
462
+ handler: withFieldWrite(({ field, value }) => store.setField(field, value)),
463
+ })
464
+ ctx.effect(() => disposeConfigSet, 'prompt-customizer: config set route')
144
465
 
145
- /** Extract the agent-preset id from a standing scope key. */
146
- function agentIdOf(scopeKey) {
147
- return scopeKey && typeof scopeKey === 'object' && typeof scopeKey.agentPreset === 'string'
148
- ? scopeKey.agentPreset
149
- : undefined
150
- }
466
+ const disposeConfigUnset = webserver.register({
467
+ kind: 'exact',
468
+ path: '/api/prompt-customizer/config/unset',
469
+ handler: withFieldWrite(({ field }) => store.setField(field, undefined)),
470
+ })
471
+ ctx.effect(() => disposeConfigUnset, 'prompt-customizer: config unset route')
151
472
 
152
- /**
153
- * Merge the shared default config with the given agent preset's overlay.
154
- * The overlay's own arrays/objects replace the default's per field; the
155
- * default applies to every agent the overlay does not mention.
156
- */
157
- export function mergeForAgent(raw, agentId) {
158
- const byAgent = raw.byAgent ?? {}
159
- const over = (agentId && byAgent[agentId]) || {}
160
- return {
161
- sections: over.sections ?? raw.sections ?? [],
162
- replace: { ...(raw.replace ?? {}), ...(over.replace ?? {}) },
163
- inject: mergeInject(raw.inject, over.inject),
164
- tools: {
165
- exclude: over.tools?.exclude ?? raw.tools?.exclude ?? [],
166
- include: over.tools?.include ?? raw.tools?.include ?? [],
167
- },
473
+ // 批量保存路由:UI「统一保存按钮」的唯一写盘通道。body { target?, patch },
474
+ // patch 只认业务字段(只认下列业务字段,含每阶段独立段名单),字段值为 null 表示
475
+ // 删除(回退默认/全局)。target 缺省写全局顶层;target 为 agent 预设 id
476
+ // 时写 overrides[id],清空后的空 override 连带删除,避免配置文件堆积空壳。
477
+ const APPLY_FIELDS = ['sections', 'sectionsBootstrap', 'sectionsCompaction', 'replace', 'inject', 'tools']
478
+ const disposeConfigApply = webserver.register({
479
+ kind: 'exact',
480
+ path: '/api/prompt-customizer/config/apply',
481
+ handler: async (req, res) => {
482
+ try {
483
+ const body = await readJsonBody(req)
484
+ const patch = body?.patch
485
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
486
+ writeJson(res, 400, { ok: false, error: 'patch object is required' })
487
+ return
488
+ }
489
+ const target = typeof body.target === 'string' && body.target.length > 0 ? body.target : undefined
490
+ const base = { ...(store.raw() ?? {}) }
491
+ const applyPatch = (obj) => {
492
+ for (const field of APPLY_FIELDS) {
493
+ if (!Object.hasOwn(patch, field)) continue
494
+ if (patch[field] === null) delete obj[field]
495
+ else obj[field] = patch[field]
496
+ }
497
+ }
498
+ if (target === undefined) {
499
+ applyPatch(base)
500
+ } else {
501
+ const overrides = { ...(base.overrides ?? {}) }
502
+ const ovr = { ...(overrides[target] ?? {}) }
503
+ applyPatch(ovr)
504
+ if (Object.keys(ovr).length > 0) overrides[target] = ovr
505
+ else delete overrides[target]
506
+ if (Object.keys(overrides).length > 0) base.overrides = overrides
507
+ else delete base.overrides
508
+ }
509
+ store.writeSection(base)
510
+ writeJson(res, 200, { ok: true, config: read() })
511
+ } catch (error) {
512
+ writeJson(res, 400, {
513
+ ok: false,
514
+ error: String(error && error.message ? error.message : error),
515
+ })
516
+ }
517
+ },
518
+ })
519
+ ctx.effect(() => disposeConfigApply, 'prompt-customizer: config apply route')
168
520
  }
169
521
  }
170
522
 
171
- /** Merge default + per-agent inject lists: same-name entries are overridden. */
172
- function mergeInject(base, over) {
173
- const map = new Map((base ?? []).map((item) => [item.name, item]))
174
- for (const item of over ?? []) map.set(item.name, item)
175
- return [...map.values()]
176
- }
177
-
178
- /** Resolve `value` but never wait longer than `ms`; fall back to `fallback`. */
523
+ /** 解析 `value`,但最多等待 `ms` 毫秒,超时回退到 `fallback`。 */
179
524
  function withTimeout(value, ms, fallback) {
180
525
  return Promise.race([
181
526
  Promise.resolve(value),
@@ -184,12 +529,25 @@ function withTimeout(value, ms, fallback) {
184
529
  }
185
530
 
186
531
  /**
187
- * Resolve the `standard` agent's standing scope key when a roster is present.
532
+ * 宿主 authoring API(`agentPresets.copy`)抛错 → HTTP 状态码。
188
533
  *
189
- * `standingKeyFor` mounts a whole agent preset (compose plugins) which can be
190
- * slow or depend on services that are not ready yet — for a read-only inventory
191
- * browse we must never let that hang the request. If it does not settle within
192
- * a short budget we fall back to the global layer (scopeKey = undefined).
534
+ * 按错误类的名字判定而不是 import 宿主包:`@deepseek-ai/dsh-agent-presets` 是宿主
535
+ * 依赖,本插件不声明它,子路径导出也不保证存在;名字对不上时退回 500,错误文本
536
+ * 原样回给 UI,至少不吞信息。
537
+ */
538
+ function copyErrorStatus(error) {
539
+ const kind = String(error?.constructor?.name ?? '')
540
+ if (kind === 'PresetExistsError') return 409
541
+ if (kind === 'InvalidPresetIdError' || kind === 'PresetNotWritableError' || kind === 'UnknownPresetError') return 400
542
+ return 500
543
+ }
544
+
545
+ /**
546
+ * 存在 roster 时解析 `standard` agent 的常驻 scope key。
547
+ *
548
+ * `standingKeyFor` 会挂载整个 agent 预设(组装插件),可能很慢或依赖尚未
549
+ * 就绪的服务 —— 只读的清单浏览绝不能被它挂住。若在短超时内没有结果,
550
+ * 回退到全局层(scopeKey = undefined)。
193
551
  */
194
552
  async function resolveStandardScope(ctx) {
195
553
  try {
@@ -197,29 +555,28 @@ async function resolveStandardScope(ctx) {
197
555
  if (presets && typeof presets.standingKeyFor === 'function') {
198
556
  return await withTimeout(presets.standingKeyFor('standard'), 1500, undefined)
199
557
  }
200
- } catch { /* no roster — fall back to the global layer */ }
558
+ } catch { /* 没有 roster —— 回退到全局层 */ }
201
559
  return undefined
202
560
  }
203
561
 
204
- /** Enumerate the effective sections and tools with their blocked/hidden flags. */
205
- async function buildInventory(ctx, cfg, scopeKey) {
562
+ /** 枚举当前生效的段与工具,并附上各自的屏蔽 / 隐藏标记。 */
563
+ async function buildInventory(ctx, raw, scopeKey, presetId, catalog) {
564
+ // 生效配置:目标 agent 预设的字段级 override 优先于全局默认。
565
+ const cfg = mergeConfig(raw, raw.overrides?.[presetId])
206
566
  const denied = new Set(cfg.sections ?? [])
207
567
  const replace = cfg.replace ?? {}
208
- const injectList = cfg.inject ?? []
209
- const injectOrder = new Map(injectList.map((item) => [item.name, item.order]))
210
- const te = cfg.tools ?? {}
211
- const exclude = new Set(te.exclude ?? [])
212
- const include = Array.isArray(te.include) && te.include.length > 0 ? new Set(te.include) : null
213
-
214
- // Resolve a section's text. Dynamic sections register a function; call it
215
- // with a best-effort context (same shape the assemble passes) so the
216
- // inventory can echo the real generated content for replacement. Fall back
217
- // to a marker if the function throws or returns a pending value.
568
+ const injectOrder = new Map((cfg.inject ?? []).map((item) => [item.name, item.order]))
569
+ const te = pickToolsFilter(cfg.tools, { promoted: true, boundary: -1 }) // 清单无会话概念:恒展示静态(晋级后)视图
570
+ const isHidden = (name) => (te.exclude ?? []).includes(name)
571
+
572
+ // 解析段的文本。动态段注册的是函数;用尽力而为的上下文调用它(与装配时
573
+ // 传入的形态一致),清单才能回显真实生成内容供替换编辑。函数抛错或返回
574
+ // 未决值时回退为占位标记。
218
575
  const resolveText = (section) => {
219
576
  if (typeof section.text === 'function') {
220
577
  try {
221
578
  const value = section.text({ scope: scopeKey })
222
- // Async dynamic sections return a thenable we cannot echo synchronously.
579
+ // 异步动态段返回 thenable,无法同步回显。
223
580
  if (value && typeof value.then === 'function') return '<动态生成>'
224
581
  return String(value ?? '')
225
582
  } catch { return '<动态生成>' }
@@ -227,21 +584,16 @@ async function buildInventory(ctx, cfg, scopeKey) {
227
584
  return String(section.text ?? '')
228
585
  }
229
586
 
230
- const sectionsMap = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
231
- const sections = [...sectionsMap.entries()]
587
+ // 本次这个 scope 真正见到的段与工具:只带身份与内容,标记一律现算。
588
+ const seenSections = [...ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections).entries()]
232
589
  .map(([name, section]) => ({
233
590
  name,
234
591
  order: injectOrder.has(name) ? injectOrder.get(name) : (typeof section.order === 'number' ? section.order : 0),
235
592
  text: resolveText(section),
236
- active: !denied.has(name),
237
- replaced: Object.hasOwn(replace, name),
238
593
  }))
239
- .sort((a, b) => a.order - b.order)
240
-
241
- let tools = []
242
- // Enumerate tools from the resolved scope, falling back to the global layer
243
- // when the scope key was unavailable (e.g. the standing mount timed out), so
244
- // the catalog is never empty just because agent composition was not ready.
594
+ const seenTools = []
595
+ // 从解析出的 scope 枚举工具;scope key 不可用时(如常驻挂载超时)回退到
596
+ // 全局层,避免仅仅因为 agent 组装未就绪就返回空目录。
245
597
  try {
246
598
  const byName = new Map()
247
599
  for (const scope of [scopeKey, undefined]) {
@@ -252,69 +604,177 @@ async function buildInventory(ctx, cfg, scopeKey) {
252
604
  }
253
605
  if (byName.size > 0) break
254
606
  }
255
- tools = [...byName.values()]
256
- .map((tool) => ({
257
- name: tool.name,
258
- description: typeof tool.description === 'string' ? tool.description : '',
259
- hidden: include ? !include.has(tool.name) : exclude.has(tool.name),
260
- }))
261
- .sort((a, b) => a.name.localeCompare(b.name))
262
- } catch { /* tools registry unresolved — return empty list */ }
607
+ for (const tool of byName.values()) {
608
+ seenTools.push({ name: tool.name, description: typeof tool.description === 'string' ? tool.description : '' })
609
+ }
610
+ } catch { /* 工具注册表未就绪 —— 本次不增量并入 */ }
611
+
612
+ // 并进累积登记表,再按并集出名单:本 scope 见到的排在前面(顺序更贴近当前
613
+ // 预设),其它预设见过的追加在后。同名内容后见到者赢,条目只增不减。
614
+ if (catalog !== undefined) catalog.observe({ sections: seenSections, tools: seenTools })
615
+ const pool = catalog === undefined ? { sections: seenSections, tools: seenTools } : catalog.read()
616
+ const seenNames = new Set(seenSections.map((x) => x.name))
617
+ const sections = [...seenSections, ...pool.sections.filter((x) => !seenNames.has(x.name))]
618
+ .map((item) => ({ ...item, active: !denied.has(item.name), replaced: Object.hasOwn(replace, item.name) }))
619
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
620
+ const seenToolNames = new Set(seenTools.map((x) => x.name))
621
+ const tools = [...seenTools, ...pool.tools.filter((x) => !seenToolNames.has(x.name))]
622
+ .map((item) => ({ ...item, hidden: isHidden(item.name) }))
623
+ .sort((a, b) => a.name.localeCompare(b.name))
263
624
 
264
625
  return { sections, tools, config: cfg }
265
626
  }
266
627
 
267
628
  /**
268
- * Resolve the scope key a preview request targets. An explicit `?scope=<id>`
269
- * requests that agent preset's standing scope; otherwise fall back to the
270
- * `standard` agent (same default the inventory uses). Always resolves within
271
- * the same short timeout so a slow standing mount cannot hang the request.
629
+ * 解析一个 agent 预设 id 的常驻 scope key(供无 agent 的只读路由使用)。
630
+ * 显式点名某预设时给挂载留足时间(15s):standingKeyFor 要组装整个预设
631
+ * 插件树,慢挂载很常见,且挂载成功后 roster 常驻 memo,后续请求零开销。
632
+ * 注意 roster 的 standingKeyFor 对未知 id 也会兜底返回 standard scope,
633
+ * 因此先用 list() 确认 id 存在 —— 否则「预设不存在」会被误当成解析成功。
272
634
  */
273
- async function previewScope(ctx, req) {
274
- const id = req.query && req.query.scope
275
- if (typeof id === 'string' && id.length > 0) {
276
- try {
277
- const presets = ctx.get('agentPresets')
278
- if (presets && typeof presets.standingKeyFor === 'function') {
279
- const key = await withTimeout(presets.standingKeyFor(id), 1500, undefined)
280
- if (key) return key
635
+ async function resolveScopeFor(ctx, id) {
636
+ if (!id) return undefined
637
+ try {
638
+ const presets = ctx.get('agentPresets')
639
+ if (presets && typeof presets.standingKeyFor === 'function') {
640
+ if (typeof presets.list === 'function') {
641
+ const list = await withTimeout(presets.list(), 15000, undefined)
642
+ if (Array.isArray(list) && !list.some((p) => p.id === id)) return undefined
281
643
  }
282
- } catch { /* unknown preset — fall back to the standard agent */ }
283
- }
284
- return resolveStandardScope(ctx)
644
+ return await withTimeout(presets.standingKeyFor(id), 15000, undefined)
645
+ }
646
+ } catch { /* 未知预设或挂载失败(如预设依赖缺失)—— 回退全局层 */ }
647
+ return undefined
285
648
  }
286
649
 
287
- /** List every agent preset for the per-agent editor UI. */
288
- async function listAgents(ctx) {
289
- try {
290
- const presets = ctx.get('agentPresets')
291
- if (!presets || typeof presets.list !== 'function') return { ok: true, agents: [], defaultAgent: undefined }
292
- const list = await withTimeout(presets.list(), 1500, [])
293
- const agents = (list ?? []).map((preset) => ({
294
- id: preset.id,
295
- name: typeof preset.name === 'string' && preset.name.length > 0 ? preset.name : preset.id,
296
- description: typeof preset.description === 'string' ? preset.description : '',
297
- order: typeof preset.order === 'number' ? preset.order : 0,
298
- }))
299
- return { ok: true, agents, defaultAgent: typeof presets.defaultId === 'string' ? presets.defaultId : undefined }
300
- } catch (error) {
301
- return { ok: false, error: String(error && error.message ? error.message : error), agents: [] }
650
+ /**
651
+ * Resolve the scope key + preset hint a preview request targets. An explicit
652
+ * `?scope=<id>` requests that agent preset's standing scope; otherwise fall
653
+ * back to the `standard` agent (same default the inventory uses). Always
654
+ * resolves within the same short timeout so a slow standing mount cannot hang
655
+ * the request. The hint makes our own assemble filter apply that preset's
656
+ * field-level override during the preview waterfall.
657
+ *
658
+ * 返回的 `scopeResolved` 表示「请求的目标是否解析出了自己的 scope」:显式
659
+ * id 挂载失败时展示会回落 standard scope,但标记为 false,让 UI 明确警示
660
+ * 「预览的不是该预设的原生装配」。
661
+ */
662
+ async function previewTarget(ctx, req) {
663
+ const id = queryOf(req).get('scope') ?? undefined
664
+ const resolved = id ? await resolveScopeFor(ctx, id) : await resolveStandardScope(ctx)
665
+ const scopeKey = resolved ?? (id ? await resolveStandardScope(ctx) : undefined)
666
+ // `?phase=`:bootstrap / compaction / active 三态预览;其它值一律回退实际推导。
667
+ const rawPhase = queryOf(req).get('phase') ?? undefined
668
+ const phase = rawPhase === 'bootstrap' || rawPhase === 'compaction' || rawPhase === 'active'
669
+ ? rawPhase
670
+ : undefined
671
+ return { scopeKey, hintId: id, phase, scopeResolved: resolved !== undefined }
672
+ }
673
+
674
+ /**
675
+ * 按预览阶段合成一个最小伪会话 agent。预设原生的阶段裁剪插件(zero-tool
676
+ * bootstrap / router-bootstrap / warmup 等)在 assemble 瀑布流里只读
677
+ * `session.id / session.events / session.header / agent.options.model` 推导
678
+ * 晋级状态并动态裁剪工具目录 —— 合成的 durable 事件让这些规则在只读预览
679
+ * 里按所选阶段真实运行(而非挤在常驻视图):
680
+ * - bootstrap:空事件(boundary -1,未晋级,首轮形态)
681
+ * - compaction:一条 compaction/end(压缩后未重新晋级)
682
+ * - active:一条晋级信号(assistant/message,event 形状见各预设的
683
+ * compaction-epoch.mjs —— 只认 type 与 seq)
684
+ */
685
+ export function fakeAgentFor(phase, presetId) {
686
+ const events = phase === 'compaction'
687
+ ? [{ type: 'compaction/end', seq: 1 }]
688
+ : phase === 'active'
689
+ ? [{ type: 'assistant/message', seq: 1 }]
690
+ : []
691
+ return {
692
+ session: {
693
+ id: `prompt-customizer-preview-${phase}`,
694
+ events,
695
+ header: {
696
+ delegationDepth: 0,
697
+ agentPreset: presetId,
698
+ cwd: process.cwd(),
699
+ meta: {},
700
+ },
701
+ },
702
+ options: { provider: '', model: '' },
302
703
  }
303
704
  }
304
705
 
305
706
  /**
306
- * Render the FINAL system prompt for a scope: run the full assemble waterfall
307
- * (so every plugin's filter, including our own, is applied), then render the
308
- * surviving sections to text. This is exactly what the model would see.
707
+ * 渲染某个 scope 的最终系统提示词:跑完整的装配瀑布流(所有插件的过滤、
708
+ * 包括我们自己的都会生效),再把幸存的段拼成文本。这就是模型实际看到的内容。
709
+ *
710
+ * `phase` 给定时额外携带伪会话 agent,让预设原生的阶段裁剪规则(引导期
711
+ * 收敛目录等)也按所选阶段真实运行;伪会话触发任何插件异常时降级为无
712
+ * 会话装配(原生规则视为已晋级 / 全目录)并标记 degraded,预览永远可用。
713
+ * 同时返回注册表原始目录总数 registryTotal,与模型可见目录形成对照
714
+ * (如 PTC / Code Mode 把全目录包装成单一 run_code)。
309
715
  */
310
- async function buildPreview(ctx, scopeKey) {
311
- const assembled = await ctx.systemPrompt.assemble({ scope: scopeKey })
716
+ async function buildPreview(ctx, scopeKey, hintId, phase, catalog) {
717
+ // promptCustomizerPreset / promptCustomizerPhase 都是本插件的自定义装配
718
+ // 字段:让我们的过滤器在这次装配里按该 agent 预设的 override 与指定阶段
719
+ // 视图生效(AssembleContext 允许插件自定义字段)。
720
+ const hint = {
721
+ scope: scopeKey,
722
+ promptCustomizerPreset: hintId,
723
+ promptCustomizerPhase: phase,
724
+ // 请求预过滤视图(base):本插件的过滤器把进入过滤前的段 / 工具原文
725
+ // 连同屏蔽 / 隐藏标记一起附在装配结果上,作为三个 Tab 的统一数据源。
726
+ promptCustomizerBase: true,
727
+ }
728
+ let assembled
729
+ let degraded = false
730
+ try {
731
+ assembled = await ctx.systemPrompt.assemble(
732
+ phase ? { ...hint, agent: fakeAgentFor(phase, hintId) } : hint,
733
+ )
734
+ } catch {
735
+ // 伪会话让某个预设插件抛错了:退回无会话装配,原生规则不参与本次预览。
736
+ degraded = phase !== undefined
737
+ assembled = await ctx.systemPrompt.assemble(hint)
738
+ }
312
739
  const sections = Array.isArray(assembled.sections)
313
740
  ? assembled.sections.map((section) => ({ name: section.name, text: String(section.text ?? '') }))
314
741
  : []
742
+ // 预过滤视图(base):与 post 视图来自同一次装配,三个 Tab 共用同一份数据。
743
+ const baseView = (assembled && typeof assembled === 'object' && assembled.promptCustomizerBaseView) || {}
744
+ // 预览带 scope,是「浏览其它预设」时唯一真正跑过该预设装配的入口 —— 把它
745
+ // 见到的段与工具并进池子(base 视图不含 order,新条目由登记表按追加序补)。
746
+ if (catalog !== undefined) {
747
+ catalog.observe({
748
+ sections: (Array.isArray(baseView.sections) ? baseView.sections : []).map((sec) => ({ name: sec.name, text: sec.text })),
749
+ tools: (Array.isArray(baseView.tools) ? baseView.tools : []).map((tool) => ({ name: tool.name, description: tool.description })),
750
+ })
751
+ }
752
+ const registryTools = registryToolNames(ctx, scopeKey)
753
+ const registryTotal = registryTools.length
754
+ // 兜底探测(机制无关):本插件产出的段有多少没进最终装配。下游丢弃段的规则
755
+ // 不止 persona complete 一种(实测 liangshen 引导期由预设自身规则 20 → 1 段,
756
+ // 注册表里没有 complete 段),所以按名字比对结果而不是只看注册表标记。
757
+ // emitted 缺席 = 我们的过滤器没跑(异常回退未加工装配),此时无从比较。
758
+ const emitted = Array.isArray(assembled.promptCustomizerEmitted) ? assembled.promptCustomizerEmitted : null
759
+ const survivedNames = new Set(sections.map((section) => String(section.name)))
760
+ const droppedNames = emitted === null ? [] : emitted.filter((name) => !survivedNames.has(String(name)))
761
+ const lostSections = droppedNames.length > 0
762
+ ? { emitted: emitted.length, survived: sections.length, dropped: droppedNames.length }
763
+ : undefined
315
764
  return {
316
765
  ok: true,
317
766
  scope: scopeKey,
767
+ degraded,
768
+ registryTotal,
769
+ /** 该 scope 注册表的工具名清单:供界面区分「在本预设里、只是被该阶段裁掉」
770
+ * 与「根本不属于本预设(无法加回)」。 */
771
+ registryTools,
772
+ /** 非空 = 该 scope 有 complete 段整段接管最终提示词(值为那段的名字)。 */
773
+ takenOverBy: assembled && typeof assembled === 'object'
774
+ ? (typeof assembled.promptCustomizerTakenOver === 'string' ? assembled.promptCustomizerTakenOver : undefined)
775
+ : undefined,
776
+ /** 非空 = 本插件产出的段有若干被下游装配规则丢弃(计数,不含段名列表)。 */
777
+ lostSections,
318
778
  sections,
319
779
  text: renderPreviewText(assembled),
320
780
  tools: Array.isArray(assembled.tools)
@@ -323,10 +783,70 @@ async function buildPreview(ctx, scopeKey) {
323
783
  description: typeof tool.description === 'string' ? tool.description : '',
324
784
  }))
325
785
  : [],
786
+ // 预过滤视图:段(含该阶段独立屏蔽标记 blocked / 替换标记)与工具
787
+ // (含该阶段隐藏标记 hidden)—— 供提示词 / 工具 Tab 渲染「每阶段
788
+ // 独立名单」,被屏蔽 / 被隐藏的条目仍保留在此可反选。
789
+ baseSections: Array.isArray(baseView.sections) ? baseView.sections : [],
790
+ baseTools: Array.isArray(baseView.tools) ? baseView.tools : [],
326
791
  }
327
792
  }
328
793
 
329
- /** Join the surviving sections into the final prompt text (like dsh's renderPrompt). */
794
+ /** 注册表的工具名清单(解析出的 scope 优先,回退全局层)。任何一步失败都返回
795
+ * 空数组,绝不让预览失败;长度即模型可见目录的对照总数。 */
796
+ function registryToolNames(ctx, scopeKey) {
797
+ try {
798
+ const byName = new Map()
799
+ for (const scope of [scopeKey, undefined]) {
800
+ let schemas = []
801
+ try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
802
+ for (const tool of schemas) if (tool.name) byName.set(tool.name, tool)
803
+ if (byName.size > 0) break
804
+ }
805
+ return [...byName.keys()]
806
+ } catch { return [] }
807
+ }
808
+
809
+ /**
810
+ * 把该阶段 `add` 名单里、当前目录还没有的工具,从注册表查回 schema 追加回去
811
+ *(「把被该阶段裁掉、但注册表里仍有的工具加回来」)。`exclude` 优先 —— 同一
812
+ * 名字两者都在 = 隐藏,不加;目录里已有的不重复;注册表查不到的名字跳过(通常
813
+ * 是别的预设独有的工具,当前预设没有可查的 schema,加不进来)。
814
+ */
815
+ function appendAddedTools(ctx, scopeKey, tools, add, exclude) {
816
+ if (!Array.isArray(add) || add.length === 0) return tools
817
+ const excluded = new Set(exclude ?? [])
818
+ const present = new Set(tools.map((tool) => tool.name))
819
+ const wanted = add.filter((name) => !present.has(name) && !excluded.has(name))
820
+ if (wanted.length === 0) return tools
821
+ const registry = new Map()
822
+ for (const scope of [scopeKey, undefined]) {
823
+ let schemas = []
824
+ try { schemas = ctx.tools.schemas(scope) ?? [] } catch { schemas = [] }
825
+ for (const tool of schemas) if (tool.name) registry.set(tool.name, tool)
826
+ if (registry.size > 0) break
827
+ }
828
+ const extra = wanted.map((name) => registry.get(name)).filter(Boolean)
829
+ return extra.length > 0 ? [...tools, ...extra] : tools
830
+ }
831
+
832
+ /**
833
+ * 查出该 scope 里是否有一段以 `complete: true` 注册(persona 整段接管)。
834
+ * 宿主 `SystemPrompt.assemble()` 的契约:瀑布流的返回值虽然权威,但存在有效
835
+ * complete 段时会在瀑布流**之后**把 sections 强制还原成那一条段——因此本插件
836
+ * 在瀑布流里做的屏蔽 / 替换 / 注入 / 排序对这类预设完全不进最终提示词(工具
837
+ * 过滤不受影响)。插件侧无法绕开,只能探出来交给 UI 明确告知用户。
838
+ */
839
+ function detectCompletePrompt(ctx, scopeKey) {
840
+ try {
841
+ const sections = ctx.systemPrompt.layers.merge(scopeKey, (layer) => layer.sections)
842
+ for (const section of sections.values()) {
843
+ if (section && section.complete === true) return String(section.name)
844
+ }
845
+ } catch { /* 注册表未就绪 / 无 layers 能力 —— 视为未接管 */ }
846
+ return undefined
847
+ }
848
+
849
+ /** 把幸存的段拼成最终提示词文本(与 dsh 的 renderPrompt 行为一致)。 */
330
850
  function renderPreviewText(assembled) {
331
851
  return (Array.isArray(assembled.sections) ? assembled.sections : [])
332
852
  .map((section) => String(section.text ?? '').trim())
@@ -337,4 +857,12 @@ function renderPreviewText(assembled) {
337
857
  function writeJson(res, status, body) {
338
858
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
339
859
  res.end(JSON.stringify(body))
860
+ }
861
+
862
+ /** 解析请求 URL 里的查询参数(宿主共享 webserver 的 req 是原始
863
+ * IncomingMessage,没有 Express 风格的 query 属性)。 */
864
+ function queryOf(req) {
865
+ const url = String(req?.url ?? '')
866
+ const i = url.indexOf('?')
867
+ return new URLSearchParams(i >= 0 ? url.slice(i + 1) : '')
340
868
  }