dsh-prompt-customizer 0.4.0 → 0.4.2

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/cordis.patch.yml CHANGED
@@ -1,9 +1,9 @@
1
- # dsh-prompt-customizer bundle patch: inserts the dual-face plugin row into
2
- # the web profile roster. Applied as a profile bundle layer (the
3
- # `dsh.bundle.patch` manifest field). The node half (exports ".") runs in the
4
- # host process (settings namespace, prompt/tool filtering, inventory route);
5
- # the `dsh.client` declaration in package.json loads the browser half
6
- # (exports "./client", served at /plugins/prompt-customizer/client.js).
7
- - insert:
8
- - id: prompt-customizer
1
+ # dsh-prompt-customizer bundle patch: inserts the dual-face plugin row into
2
+ # the web profile roster. Applied as a profile bundle layer (the
3
+ # `dsh.bundle.patch` manifest field). The node half (exports ".") runs in the
4
+ # host process (settings namespace, prompt/tool filtering, inventory route);
5
+ # the `dsh.client` declaration in package.json loads the browser half
6
+ # (exports "./client", served at /plugins/prompt-customizer/client.js).
7
+ - insert:
8
+ - id: prompt-customizer
9
9
  name: 'dsh-prompt-customizer'
package/lib/catalog.js CHANGED
@@ -1,131 +1,131 @@
1
- /**
2
- * 「本系统全部提示词 / 全部工具」的累积登记表 —— `catalog.yaml`。
3
- *
4
- * 段与工具都挂在各 agent 预设自己的 scope 里,要看到某个预设独有的名字必须先
5
- * 挂载它(`standingKeyFor` 组装整棵插件树,单个可慢到十几秒)。清单面板要的却是
6
- * 「不随编辑目标切换、只增不减」的全景,所以这里不做全量预热,而是懒式累积:
7
- * 每次为任一 scope 构建清单 / 预览,就把这次真正看到的段与工具并进登记表 ——
8
- * 同名后见到者赢,新名追加,绝不因为切换预设而变少。
9
- *
10
- * 文件只是派生缓存:删掉它会随浏览重新长出来,不含任何用户配置。因此登记表只存
11
- * 身份与内容(段名 / 顺序 / 文本,工具名 / 描述),**不存**屏蔽、替换、隐藏这类
12
- * 按作用域计算的标记 —— 那些永远在响应时按当前生效配置现算。
13
- */
14
-
15
- import path from 'node:path'
16
- import { createConfigStore } from './store.js'
17
-
18
- /** 只取已定义字段,避免 `{...旧, ...新}` 用 undefined 把旧值抹成空洞。 */
19
- function definedFields(item) {
20
- const patch = {}
21
- for (const [key, value] of Object.entries(item)) {
22
- if (value !== undefined) patch[key] = value
23
- }
24
- return patch
25
- }
26
-
27
- function isName(value) {
28
- return typeof value === 'string' && value.length > 0
29
- }
30
-
31
- /**
32
- * 把一次所见并进已有名单:同名覆盖(后见到者赢,保持原有位置),新名追加。
33
- * `orderOf` 给缺少 order 的新条目补一个稳定下标(工具池不需要)。
34
- * 返回 `{ list, changed }` —— changed 为 false 时调用方可以不落盘。
35
- */
36
- export function mergeSighting(prev, sighting, orderOf = null) {
37
- const list = (Array.isArray(prev) ? prev : []).map((item) => ({ ...item }))
38
- const index = new Map(list.map((item, i) => [item?.name, i]))
39
- let changed = false
40
- for (const raw of Array.isArray(sighting) ? sighting : []) {
41
- if (raw === null || typeof raw !== 'object' || !isName(raw.name)) continue
42
- const at = index.get(raw.name)
43
- if (at === undefined) {
44
- const next = { ...raw }
45
- if (orderOf !== null && next.order === undefined) next.order = orderOf(list, next)
46
- index.set(raw.name, list.length)
47
- list.push(next)
48
- changed = true
49
- continue
50
- }
51
- const patch = definedFields(raw)
52
- const merged = { ...list[at], ...patch }
53
- if (JSON.stringify(merged) === JSON.stringify(list[at])) continue
54
- list[at] = merged
55
- changed = true
56
- }
57
- return { list, changed }
58
- }
59
-
60
- /** 创建一个累积登记表。`dir` 与配置文件同目录;读不到 / 坏文件一律从空开始。 */
61
- export function createCatalog({ dir, file, warn } = {}) {
62
- const target = file ?? path.join(dir ?? '.', 'catalog.yaml')
63
- const store = createConfigStore({
64
- file: target,
65
- warn,
66
- header: '# dsh-prompt-customizer 段 / 工具累积登记表(派生缓存,非配置)。\n'
67
- + '# 由面板浏览各 agent 预设时自动累积;删掉本文件会随下次浏览重新长出来。\n',
68
- })
69
-
70
- /** 归一化磁盘内容:只认数组里的具名条目,段保留 order / text。 */
71
- const normalize = (raw, kind) => {
72
- const list = Array.isArray(raw) ? raw : []
73
- if (kind === 'sections') {
74
- return list
75
- .filter((x) => x !== null && typeof x === 'object' && isName(x.name))
76
- .map((x) => ({ name: x.name, order: typeof x.order === 'number' ? x.order : undefined, text: typeof x.text === 'string' ? x.text : '' }))
77
- .map((x) => (x.order === undefined ? { name: x.name, text: x.text } : { name: x.name, order: x.order, text: x.text }))
78
- }
79
- return list
80
- .filter((x) => x !== null && typeof x === 'object' && isName(x.name))
81
- .map((x) => ({ name: x.name, description: typeof x.description === 'string' ? x.description : '' }))
82
- }
83
-
84
- const load = () => {
85
- let doc
86
- try { doc = store.readResolved() ?? {} } catch { doc = {} }
87
- return {
88
- sections: normalize(doc.sections, 'sections'),
89
- tools: normalize(doc.tools, 'tools'),
90
- }
91
- }
92
-
93
- let state = load()
94
-
95
- return {
96
- /** 当前并集(按登记顺序;调用方自己现算标记与排序)。 */
97
- read() {
98
- return { sections: state.sections.map((x) => ({ ...x })), tools: state.tools.map((x) => ({ ...x })) }
99
- },
100
-
101
- /**
102
- * 并进一次所见。段的 order 只在所见提供时才更新(预览路径没有 order,
103
- * 不该把已知顺序打乱);新段用「当前最大 order + 1」落位,保持追加在尾部。
104
- */
105
- observe({ sections = [], tools = [] } = {}) {
106
- // 与 config.yaml 同构的语义:外部编辑 / 删除即时生效(store 是 mtime 懒
107
- // 加载的,这里取一次最新状态再合并)。否则删掉本文件后,内存里的旧池子
108
- // 会在下次请求被原样写回来,「删掉随浏览重建」就成了假话。
109
- state = load()
110
- let changed = false
111
- const nextOrder = (list) => {
112
- let max = -1
113
- for (const item of list) if (typeof item.order === 'number' && item.order > max) max = item.order
114
- return max + 1
115
- }
116
- const mergedSections = mergeSighting(state.sections, sections, (list) => nextOrder(list))
117
- if (mergedSections.changed) { state.sections = mergedSections.list; changed = true }
118
- const mergedTools = mergeSighting(state.tools, tools)
119
- if (mergedTools.changed) { state.tools = mergedTools.list; changed = true }
120
- if (!changed) return false
121
- // 落盘失败只影响缓存持久性,绝不让清单 / 预览请求失败。
122
- try { store.writeSection(state) } catch (error) {
123
- try { warn?.(`prompt-customizer: catalog.yaml 写入失败(本次仅内存累积):${String(error && error.message ? error.message : error)}`) } catch { /* logger 缺席 */ }
124
- }
125
- return true
126
- },
127
-
128
- /** 测试 / 排查用:重新从磁盘读一次。 */
129
- reload() { state = load(); return state },
130
- }
131
- }
1
+ /**
2
+ * 「本系统全部提示词 / 全部工具」的累积登记表 —— `catalog.yaml`。
3
+ *
4
+ * 段与工具都挂在各 agent 预设自己的 scope 里,要看到某个预设独有的名字必须先
5
+ * 挂载它(`standingKeyFor` 组装整棵插件树,单个可慢到十几秒)。清单面板要的却是
6
+ * 「不随编辑目标切换、只增不减」的全景,所以这里不做全量预热,而是懒式累积:
7
+ * 每次为任一 scope 构建清单 / 预览,就把这次真正看到的段与工具并进登记表 ——
8
+ * 同名后见到者赢,新名追加,绝不因为切换预设而变少。
9
+ *
10
+ * 文件只是派生缓存:删掉它会随浏览重新长出来,不含任何用户配置。因此登记表只存
11
+ * 身份与内容(段名 / 顺序 / 文本,工具名 / 描述),**不存**屏蔽、替换、隐藏这类
12
+ * 按作用域计算的标记 —— 那些永远在响应时按当前生效配置现算。
13
+ */
14
+
15
+ import path from 'node:path'
16
+ import { createConfigStore } from './store.js'
17
+
18
+ /** 只取已定义字段,避免 `{...旧, ...新}` 用 undefined 把旧值抹成空洞。 */
19
+ function definedFields(item) {
20
+ const patch = {}
21
+ for (const [key, value] of Object.entries(item)) {
22
+ if (value !== undefined) patch[key] = value
23
+ }
24
+ return patch
25
+ }
26
+
27
+ function isName(value) {
28
+ return typeof value === 'string' && value.length > 0
29
+ }
30
+
31
+ /**
32
+ * 把一次所见并进已有名单:同名覆盖(后见到者赢,保持原有位置),新名追加。
33
+ * `orderOf` 给缺少 order 的新条目补一个稳定下标(工具池不需要)。
34
+ * 返回 `{ list, changed }` —— changed 为 false 时调用方可以不落盘。
35
+ */
36
+ export function mergeSighting(prev, sighting, orderOf = null) {
37
+ const list = (Array.isArray(prev) ? prev : []).map((item) => ({ ...item }))
38
+ const index = new Map(list.map((item, i) => [item?.name, i]))
39
+ let changed = false
40
+ for (const raw of Array.isArray(sighting) ? sighting : []) {
41
+ if (raw === null || typeof raw !== 'object' || !isName(raw.name)) continue
42
+ const at = index.get(raw.name)
43
+ if (at === undefined) {
44
+ const next = { ...raw }
45
+ if (orderOf !== null && next.order === undefined) next.order = orderOf(list, next)
46
+ index.set(raw.name, list.length)
47
+ list.push(next)
48
+ changed = true
49
+ continue
50
+ }
51
+ const patch = definedFields(raw)
52
+ const merged = { ...list[at], ...patch }
53
+ if (JSON.stringify(merged) === JSON.stringify(list[at])) continue
54
+ list[at] = merged
55
+ changed = true
56
+ }
57
+ return { list, changed }
58
+ }
59
+
60
+ /** 创建一个累积登记表。`dir` 与配置文件同目录;读不到 / 坏文件一律从空开始。 */
61
+ export function createCatalog({ dir, file, warn } = {}) {
62
+ const target = file ?? path.join(dir ?? '.', 'catalog.yaml')
63
+ const store = createConfigStore({
64
+ file: target,
65
+ warn,
66
+ header: '# dsh-prompt-customizer 段 / 工具累积登记表(派生缓存,非配置)。\n'
67
+ + '# 由面板浏览各 agent 预设时自动累积;删掉本文件会随下次浏览重新长出来。\n',
68
+ })
69
+
70
+ /** 归一化磁盘内容:只认数组里的具名条目,段保留 order / text。 */
71
+ const normalize = (raw, kind) => {
72
+ const list = Array.isArray(raw) ? raw : []
73
+ if (kind === 'sections') {
74
+ return list
75
+ .filter((x) => x !== null && typeof x === 'object' && isName(x.name))
76
+ .map((x) => ({ name: x.name, order: typeof x.order === 'number' ? x.order : undefined, text: typeof x.text === 'string' ? x.text : '' }))
77
+ .map((x) => (x.order === undefined ? { name: x.name, text: x.text } : { name: x.name, order: x.order, text: x.text }))
78
+ }
79
+ return list
80
+ .filter((x) => x !== null && typeof x === 'object' && isName(x.name))
81
+ .map((x) => ({ name: x.name, description: typeof x.description === 'string' ? x.description : '' }))
82
+ }
83
+
84
+ const load = () => {
85
+ let doc
86
+ try { doc = store.readResolved() ?? {} } catch { doc = {} }
87
+ return {
88
+ sections: normalize(doc.sections, 'sections'),
89
+ tools: normalize(doc.tools, 'tools'),
90
+ }
91
+ }
92
+
93
+ let state = load()
94
+
95
+ return {
96
+ /** 当前并集(按登记顺序;调用方自己现算标记与排序)。 */
97
+ read() {
98
+ return { sections: state.sections.map((x) => ({ ...x })), tools: state.tools.map((x) => ({ ...x })) }
99
+ },
100
+
101
+ /**
102
+ * 并进一次所见。段的 order 只在所见提供时才更新(预览路径没有 order,
103
+ * 不该把已知顺序打乱);新段用「当前最大 order + 1」落位,保持追加在尾部。
104
+ */
105
+ observe({ sections = [], tools = [] } = {}) {
106
+ // 与 config.yaml 同构的语义:外部编辑 / 删除即时生效(store 是 mtime 懒
107
+ // 加载的,这里取一次最新状态再合并)。否则删掉本文件后,内存里的旧池子
108
+ // 会在下次请求被原样写回来,「删掉随浏览重建」就成了假话。
109
+ state = load()
110
+ let changed = false
111
+ const nextOrder = (list) => {
112
+ let max = -1
113
+ for (const item of list) if (typeof item.order === 'number' && item.order > max) max = item.order
114
+ return max + 1
115
+ }
116
+ const mergedSections = mergeSighting(state.sections, sections, (list) => nextOrder(list))
117
+ if (mergedSections.changed) { state.sections = mergedSections.list; changed = true }
118
+ const mergedTools = mergeSighting(state.tools, tools)
119
+ if (mergedTools.changed) { state.tools = mergedTools.list; changed = true }
120
+ if (!changed) return false
121
+ // 落盘失败只影响缓存持久性,绝不让清单 / 预览请求失败。
122
+ try { store.writeSection(state) } catch (error) {
123
+ try { warn?.(`prompt-customizer: catalog.yaml 写入失败(本次仅内存累积):${String(error && error.message ? error.message : error)}`) } catch { /* logger 缺席 */ }
124
+ }
125
+ return true
126
+ },
127
+
128
+ /** 测试 / 排查用:重新从磁盘读一次。 */
129
+ reload() { state = load(); return state },
130
+ }
131
+ }
package/lib/effective.js CHANGED
@@ -1,158 +1,161 @@
1
- /**
2
- * 字段级 override 合并(方案 A)+ 阶段过滤(方案 C)。
3
- *
4
- * 合并语义:override 中**存在的字段整体接管**全局默认;空缺的字段回落
5
- * 全局。replace / inject / tools 沿用「非空才接管」(空对象 / 空列表视同
6
- * 未设置);三份段屏蔽名单(sections / sectionsBootstrap / sectionsCompaction)
7
- * 例外 —— **空数组 = 显式接管为空**(用户在该预设下解除全部屏蔽,绝不能
8
- * 回落全局把屏蔽「复活」;客户端 editView 的 `??` 语义与此一致)。
9
- */
10
-
11
- /** 非空数组才视为有效覆盖值(inject 用;屏蔽名单不适用,见上)。 */
12
- function hasList(value) {
13
- return Array.isArray(value) && value.length > 0
14
- }
15
-
16
- /** 非空对象(至少一个键)才视为有效覆盖值。 */
17
- function hasDict(value) {
18
- return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0
19
- }
20
-
21
- /** 工具配置整体是否携带至少一条有效规则(含 bootstrap / compaction 目录,
22
- * exclude 或 add 任一非空即算)。 */
23
- function hasTools(tools) {
24
- if (tools === null || typeof tools !== 'object') return false
25
- return hasList(tools.exclude) || hasList(tools.add) || hasTools(tools.bootstrap) || hasTools(tools.compaction)
26
- }
27
-
28
- /**
29
- * 字段级合并出一份生效配置。replace / inject / tools 看非空;三份段屏蔽
30
- * 名单看「字段是否存在」(空数组 = 显式接管为空,不回落全局)。
31
- */
32
- export function mergeConfig(globalCfg, ovr) {
33
- const g = globalCfg ?? {}
34
- return {
35
- sections: Array.isArray(ovr?.sections) ? ovr.sections : (g.sections ?? []),
36
- sectionsBootstrap: Array.isArray(ovr?.sectionsBootstrap) ? ovr.sectionsBootstrap : (g.sectionsBootstrap ?? []),
37
- sectionsCompaction: Array.isArray(ovr?.sectionsCompaction) ? ovr.sectionsCompaction : (g.sectionsCompaction ?? []),
38
- replace: hasDict(ovr?.replace) ? ovr.replace : (g.replace ?? {}),
39
- inject: hasList(ovr?.inject) ? ovr.inject : (g.inject ?? []),
40
- tools: hasTools(ovr?.tools) ? ovr.tools : (g.tools ?? {}),
41
- }
42
- }
43
-
44
- /**
45
- * 选出当前阶段生效的注入段:常驻期注入(`always`)始终注入;其余注入按
46
- * 阶段模式匹配 —— bootstrap(未晋级)→ `bootstrap`,active(已晋级)→
47
- * `active`,compact(压缩后仍未晋级)→ `compaction`。每个阶段的注入集
48
- * 互相独立(各自的 order 空间),用于「每阶段独立名单 + 虚拟 order」。
49
- * 传入 `status`(promotion.status() 结果 `{ promoted, boundary }`),
50
- * 不再沿用旧的布尔 promoted(旧的 compaction 窗口被硬塞进 bootstrap 期,
51
- * 无法独立排序)。
52
- */
53
- export function filterInjectByPhase(injectList, status) {
54
- const mode = status.promoted ? 'active' : status.boundary >= 0 ? 'compaction' : 'bootstrap'
55
- return (injectList ?? []).filter((item) => {
56
- const phase = item.phase === 'bootstrap' || item.phase === 'active' || item.phase === 'compaction' ? item.phase : 'always'
57
- return phase === 'always' || phase === mode
58
- })
59
- }
60
-
61
- /**
62
- * 选出当前阶段生效的工具过滤配置(三态):三个阶段**各用自己那份名单,互不
63
- * 继承** —— 已晋级 → 静态 `exclude`,未晋级 → `bootstrap.exclude`,压缩后仍未
64
- * 晋级 → `compaction.exclude`;空名单就表示「该阶段什么都不隐藏」。
65
- *
66
- * 早期版本让阶段名单在为空时回落静态字段,看起来省了一份重复配置,实际是拿
67
- * 走了阶段独立性:界面按三个阶段分别勾选,而引导期的勾选状态既反映不了静态
68
- * 名单里已隐藏的工具(显示成"可见"、模型其实看不见),往引导期名单里加一项又
69
- * 会让整个静态名单在该阶段失效(其它工具的隐藏一起蒸发)。
70
- * `status` 是 promotion.status() 的结果 `{ promoted, boundary }`。
71
- */
72
- export function pickToolsFilter(toolsCfg, status) {
73
- if (!status.promoted) {
74
- if (status.boundary >= 0) {
75
- return { exclude: toolsCfg?.compaction?.exclude ?? [], add: toolsCfg?.compaction?.add ?? [] }
76
- }
77
- return { exclude: toolsCfg?.bootstrap?.exclude ?? [], add: toolsCfg?.bootstrap?.add ?? [] }
78
- }
79
- return { exclude: toolsCfg?.exclude ?? [], add: toolsCfg?.add ?? [] }
80
- }
81
-
82
- /**
83
- * 黑名单过滤(装配钩子与 inventory 共用):把 `exclude` 里的工具从目录里拿掉。
84
- *
85
- * 刻意不支持「白名单 / 封闭式允许清单」:include 一旦生效就让 exclude 完全失效,
86
- * 而且它会把目录冻结在点名的几个上 —— 别的插件以后新增的工具一律看不见。「只留
87
- * 这几个」用 exclude 反选即可,无需第二套语义。
88
- *
89
- * 「把被裁掉但注册表里仍有的工具加回该阶段」是另一条独立通道(`add` 名单),由
90
- * 装配钩子在过滤**之后**从注册表查回 schema 追加 —— 见 lib/index.js,不在这里。
91
- */
92
- export function applyToolFilter(tools, { exclude }) {
93
- const excludeSet = new Set(exclude ?? [])
94
- if (excludeSet.size > 0) return tools.filter((tool) => !excludeSet.has(tool.name))
95
- return tools
96
- }
97
-
98
- /**
99
- * 每阶段独立段屏蔽(与工具目录三态对称):压缩受控期在「压缩后仍未晋级」
100
- * 时生效且优先于引导期;引导期在未晋级时生效;否则回落常驻期(全局
101
- * `sections`,列表为空表示不启用阶段化)。返回该阶段额外屏蔽的段名。
102
- */
103
- export function pickSectionsForStatus(config, status) {
104
- const compaction = config?.sectionsCompaction
105
- const bootstrap = config?.sectionsBootstrap
106
- if (!status.promoted && status.boundary >= 0 && Array.isArray(compaction) && compaction.length > 0) return compaction
107
- if (!status.promoted && Array.isArray(bootstrap) && bootstrap.length > 0) return bootstrap
108
- return []
109
- }
110
-
111
- /**
112
- * 段级策略:屏蔽 → 替换 → 注入 → 排序,作用于任意来源的基础段列表。
113
- *
114
- * 瀑布流装配与 assemble 强制覆盖(forceSections)共用同一份实现 —— 输入
115
- * `baseSections` 可以是瀑布流里其它插件过滤后的段,也可以是注册表
116
- * `layers.merge()` 的原始段定义。后一种输入就是「以本插件为准」的关键:
117
- * 预设插件的阶段裁段与宿主的 complete 整段接管都发生在瀑布流内/后,
118
- * 而强制覆盖在方法边界用这份原始定义重建 sections,前两者再无机会改写。
119
- * 条目只需 { name, text };order 在映射时按数组序补上(与注册表顺序一致),
120
- * 保证稳定排序,与注入段的虚拟 order 空间一致。
121
- */
122
- export function applySectionPolicy(baseSections, cfg, status) {
123
- const denied = new Set([...(cfg.sections ?? []), ...pickSectionsForStatus(cfg, status)])
124
- const replace = cfg.replace ?? {}
125
- const injectList = filterInjectByPhase(cfg.inject, status)
126
- let sections = baseSections
127
- .filter((section) => !denied.has(section.name))
128
- .map((section, i) =>
129
- Object.hasOwn(replace, section.name) ? { ...section, order: i, text: replace[section.name] } : { ...section, order: i },
130
- )
131
- for (const item of injectList) {
132
- // 被屏蔽的段直接跳过 —— 上面已经把它们过滤掉了。
133
- if (denied.has(item.name)) continue
134
- const index = sections.findIndex((section) => section.name === item.name)
135
- if (index >= 0) {
136
- // 已存在的段:覆盖 order 来控制拼接顺序。文本按「本阶段注入文本 → 该
137
- // 段当前文本」取(后者可能已被全局 replace 改过)—— injectList 已经按
138
- // 阶段筛过,所以带 phase 的文本只作用在本阶段,三个阶段各改各的;全局
139
- // replace 字典是跨阶段的(旧语义),只在没有阶段文本时生效。
140
- sections[index] = {
141
- ...sections[index],
142
- text: item.text ? item.text : sections[index].text,
143
- order: item.order,
144
- }
145
- } else if (item.custom === true || Object.hasOwn(replace, item.name) || item.text) {
146
- // 装配里没有这个名字:只有「本插件自撰的 custom 段」「被替换的段」
147
- // 「自带文本的注入段」才允许新建条目。只带 order 的空文本系统名必须
148
- // 跳过 —— 它通常来自快照里混进的别的预设独有段名(并集池会带回来),
149
- // 新建就变成一条空白幽灵段,实打实进最终提示词。
150
- sections.push({ name: item.name, order: item.order, text: item.text || replace[item.name] })
151
- }
152
- }
153
- // 虚拟 order 排序:order 缺省按 0,稳定排序保持系统段原始相对顺序;
154
- // 注入段携带各自的 order(120+ 落在系统段之后,或被重排到任意断点),
155
- // 以 order 升序决定注入顺序 —— 与「每阶段独立名单」配合即每阶段独立序。
156
- sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
157
- return sections
158
- }
1
+ /**
2
+ * 字段级 override 合并(方案 A)+ 阶段过滤(方案 C)。
3
+ *
4
+ * 合并语义:override 中**存在的字段整体接管**全局默认;空缺的字段回落
5
+ * 全局。replace / inject / tools 沿用「非空才接管」(空对象 / 空列表视同
6
+ * 未设置);三份段屏蔽名单(sections / sectionsBootstrap / sectionsCompaction)
7
+ * 例外 —— **空数组 = 显式接管为空**(用户在该预设下解除全部屏蔽,绝不能
8
+ * 回落全局把屏蔽「复活」;客户端 editView 的 `??` 语义与此一致)。
9
+ */
10
+
11
+ /** 非空数组才视为有效覆盖值(inject 用;屏蔽名单不适用,见上)。 */
12
+ function hasList(value) {
13
+ return Array.isArray(value) && value.length > 0
14
+ }
15
+
16
+ /** 非空对象(至少一个键)才视为有效覆盖值。 */
17
+ function hasDict(value) {
18
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0
19
+ }
20
+
21
+ /** 工具配置整体是否携带至少一条有效规则(含 bootstrap / compaction 目录,
22
+ * exclude 或 add 任一非空即算)。 */
23
+ function hasTools(tools) {
24
+ if (tools === null || typeof tools !== 'object') return false
25
+ return hasList(tools.exclude) || hasList(tools.add) || hasTools(tools.bootstrap) || hasTools(tools.compaction)
26
+ }
27
+
28
+ /**
29
+ * 字段级合并出一份生效配置。replace / inject / tools 看非空;三份段屏蔽
30
+ * 名单看「字段是否存在」(空数组 = 显式接管为空,不回落全局)。
31
+ */
32
+ export function mergeConfig(globalCfg, ovr) {
33
+ const g = globalCfg ?? {}
34
+ return {
35
+ sections: Array.isArray(ovr?.sections) ? ovr.sections : (g.sections ?? []),
36
+ sectionsBootstrap: Array.isArray(ovr?.sectionsBootstrap) ? ovr.sectionsBootstrap : (g.sectionsBootstrap ?? []),
37
+ sectionsCompaction: Array.isArray(ovr?.sectionsCompaction) ? ovr.sectionsCompaction : (g.sectionsCompaction ?? []),
38
+ replace: hasDict(ovr?.replace) ? ovr.replace : (g.replace ?? {}),
39
+ inject: hasList(ovr?.inject) ? ovr.inject : (g.inject ?? []),
40
+ tools: hasTools(ovr?.tools) ? ovr.tools : (g.tools ?? {}),
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 选出当前阶段生效的注入段:常驻期注入(`always`)始终注入;其余注入按
46
+ * 阶段模式匹配 —— bootstrap(未晋级)→ `bootstrap`,active(已晋级)→
47
+ * `active`,compact(压缩后仍未晋级)→ `compaction`。每个阶段的注入集
48
+ * 互相独立(各自的 order 空间),用于「每阶段独立名单 + 虚拟 order」。
49
+ * 传入 `status`(promotion.status() 结果 `{ promoted, boundary }`),
50
+ * 不再沿用旧的布尔 promoted(旧的 compaction 窗口被硬塞进 bootstrap 期,
51
+ * 无法独立排序)。
52
+ */
53
+ export function filterInjectByPhase(injectList, status) {
54
+ const mode = status.promoted ? 'active' : status.boundary >= 0 ? 'compaction' : 'bootstrap'
55
+ return (injectList ?? []).filter((item) => {
56
+ const phase = item.phase === 'bootstrap' || item.phase === 'active' || item.phase === 'compaction' ? item.phase : 'always'
57
+ return phase === 'always' || phase === mode
58
+ })
59
+ }
60
+
61
+ /**
62
+ * 选出当前阶段生效的工具过滤配置(三态):三个阶段**各用自己那份名单,互不
63
+ * 继承** —— 已晋级 → 静态 `exclude`,未晋级 → `bootstrap.exclude`,压缩后仍未
64
+ * 晋级 → `compaction.exclude`;空名单就表示「该阶段什么都不隐藏」。
65
+ *
66
+ * 早期版本让阶段名单在为空时回落静态字段,看起来省了一份重复配置,实际是拿
67
+ * 走了阶段独立性:界面按三个阶段分别勾选,而引导期的勾选状态既反映不了静态
68
+ * 名单里已隐藏的工具(显示成"可见"、模型其实看不见),往引导期名单里加一项又
69
+ * 会让整个静态名单在该阶段失效(其它工具的隐藏一起蒸发)。
70
+ * `status` 是 promotion.status() 的结果 `{ promoted, boundary }`。
71
+ */
72
+ export function pickToolsFilter(toolsCfg, status) {
73
+ if (!status.promoted) {
74
+ if (status.boundary >= 0) {
75
+ return { exclude: toolsCfg?.compaction?.exclude ?? [], add: toolsCfg?.compaction?.add ?? [] }
76
+ }
77
+ return { exclude: toolsCfg?.bootstrap?.exclude ?? [], add: toolsCfg?.bootstrap?.add ?? [] }
78
+ }
79
+ return { exclude: toolsCfg?.exclude ?? [], add: toolsCfg?.add ?? [] }
80
+ }
81
+
82
+ /**
83
+ * 黑名单过滤(装配钩子与 inventory 共用):把 `exclude` 里的工具从目录里拿掉。
84
+ *
85
+ * 刻意不支持「白名单 / 封闭式允许清单」:include 一旦生效就让 exclude 完全失效,
86
+ * 而且它会把目录冻结在点名的几个上 —— 别的插件以后新增的工具一律看不见。「只留
87
+ * 这几个」用 exclude 反选即可,无需第二套语义。
88
+ *
89
+ * 「把被裁掉但注册表里仍有的工具加回该阶段」是另一条独立通道(`add` 名单),由
90
+ * 装配钩子在过滤**之后**从注册表查回 schema 追加 —— 见 lib/index.js,不在这里。
91
+ */
92
+ export function applyToolFilter(tools, { exclude }) {
93
+ const excludeSet = new Set(exclude ?? [])
94
+ if (excludeSet.size > 0) return tools.filter((tool) => !excludeSet.has(tool.name))
95
+ return tools
96
+ }
97
+
98
+ /**
99
+ * 每阶段独立段屏蔽(与工具目录三态对称):压缩受控期在「压缩后仍未晋级」
100
+ * 时生效且优先于引导期;引导期在未晋级时生效;否则常驻期。三态互不继承:
101
+ * 每个状态只返回自己那份名单(常驻期 = `sections`,引导期 = `sectionsBootstrap`,
102
+ * 压缩受控期 = `sectionsCompaction`),一个阶段的屏蔽绝不波及另一个阶段。
103
+ * 返回该阶段生效的屏蔽段名(完整名单)。
104
+ */
105
+ export function pickSectionsForStatus(config, status) {
106
+ const resident = config?.sections
107
+ const compaction = config?.sectionsCompaction
108
+ const bootstrap = config?.sectionsBootstrap
109
+ if (!status.promoted && status.boundary >= 0) return Array.isArray(compaction) ? compaction : []
110
+ if (!status.promoted) return Array.isArray(bootstrap) ? bootstrap : []
111
+ return Array.isArray(resident) ? resident : []
112
+ }
113
+
114
+ /**
115
+ * 段级策略:屏蔽 → 替换 → 注入 → 排序,作用于任意来源的基础段列表。
116
+ *
117
+ * 瀑布流装配与 assemble 强制覆盖(forceSections)共用同一份实现 —— 输入
118
+ * `baseSections` 可以是瀑布流里其它插件过滤后的段,也可以是注册表
119
+ * `layers.merge()` 的原始段定义。后一种输入就是「以本插件为准」的关键:
120
+ * 预设插件的阶段裁段与宿主的 complete 整段接管都发生在瀑布流内/后,
121
+ * 而强制覆盖在方法边界用这份原始定义重建 sections,前两者再无机会改写。
122
+ * 条目只需 { name, text };order 在映射时按数组序补上(与注册表顺序一致),
123
+ * 保证稳定排序,与注入段的虚拟 order 空间一致。
124
+ */
125
+ export function applySectionPolicy(baseSections, cfg, status) {
126
+ const denied = new Set(pickSectionsForStatus(cfg, status))
127
+ const replace = cfg.replace ?? {}
128
+ const injectList = filterInjectByPhase(cfg.inject, status)
129
+ let sections = baseSections
130
+ .filter((section) => !denied.has(section.name))
131
+ .map((section, i) =>
132
+ Object.hasOwn(replace, section.name) ? { ...section, order: i, text: replace[section.name] } : { ...section, order: i },
133
+ )
134
+ for (const item of injectList) {
135
+ // 被屏蔽的段直接跳过 —— 上面已经把它们过滤掉了。
136
+ if (denied.has(item.name)) continue
137
+ const index = sections.findIndex((section) => section.name === item.name)
138
+ if (index >= 0) {
139
+ // 已存在的段:覆盖 order 来控制拼接顺序。文本按「本阶段注入文本 → 该
140
+ // 段当前文本」取(后者可能已被全局 replace 改过)—— injectList 已经按
141
+ // 阶段筛过,所以带 phase 的文本只作用在本阶段,三个阶段各改各的;全局
142
+ // replace 字典是跨阶段的(旧语义),只在没有阶段文本时生效。
143
+ sections[index] = {
144
+ ...sections[index],
145
+ text: item.text ? item.text : sections[index].text,
146
+ order: item.order,
147
+ }
148
+ } else if (item.custom === true || Object.hasOwn(replace, item.name) || item.text) {
149
+ // 装配里没有这个名字:只有「本插件自撰的 custom 段」「被替换的段」
150
+ // 「自带文本的注入段」才允许新建条目。只带 order 的空文本系统名必须
151
+ // 跳过 —— 它通常来自快照里混进的别的预设独有段名(并集池会带回来),
152
+ // 新建就变成一条空白幽灵段,实打实进最终提示词。
153
+ sections.push({ name: item.name, order: item.order, text: item.text || replace[item.name] })
154
+ }
155
+ }
156
+ // 虚拟 order 排序:order 缺省按 0,稳定排序保持系统段原始相对顺序;
157
+ // 注入段携带各自的 order(120+ 落在系统段之后,或被重排到任意断点),
158
+ // 以 order 升序决定注入顺序 —— 与「每阶段独立名单」配合即每阶段独立序。
159
+ sections.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
160
+ return sections
161
+ }