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.
@@ -1,134 +1,129 @@
1
- /**
2
- * 提示词段面板的纯状态逻辑(无 React / 无 IO)。
3
- * 与 src/client/SectionsTab.tsx 共享同一份实现(浏览器里随 client bundle
4
- * 打包;node --test 直接 import 本文件跑单测),保证「测试即上线代码」。
5
- *
6
- * 阶段模型:引导期(bootstrap)/ 常驻期(active)/ 压缩受控期(compaction)
7
- * 恒定全部显示(预设没有某个阶段时该部分就是空的),各自拥有独立的注入
8
- * 名单与 order 空间;屏蔽按「每阶段独立名单」写回(引导期 →
9
- * sectionsBootstrap,压缩受控期 → sectionsCompaction,常驻期 → 全局 sections)。
10
- * 入参统一是名义阶段键,不再有「同形折叠组」这种把引导期当常驻期用的形态。
11
- */
12
-
13
- /** 名义阶段键 → 屏蔽名单写回目标(常驻期用全局 sections)。 */
14
- export function sectionListOf(key) {
15
- return key === 'bootstrap' ? 'bootstrap' : key === 'compaction' ? 'compaction' : 'global'
16
- }
17
-
18
- /** 名义阶段键 → 注入阶段:压缩受控期是独立注入阶段(compaction)。 */
19
- export function injectPhaseOf(key) {
20
- return key === 'bootstrap' ? 'bootstrap' : key === 'active' ? 'active' : 'compaction'
21
- }
22
-
23
- /** 某注入段出现在哪些阶段部分:always 全阶段;bootstrap/compaction/active
24
- * 仅各自对应的阶段(与服务端 filterInjectByPhase 的三态互相独立一致)。 */
25
- export function acceptsInjectFor(key, phase) {
26
- if (phase === 'always') return true
27
- return phase === key
28
- }
29
-
30
- /** 该阶段生效的屏蔽名单(全局 + 阶段独立 的 union)。 */
31
- export function deniedNames(cfg, key) {
32
- const list = sectionListOf(key)
33
- const phase = list === 'bootstrap' ? (cfg.sectionsBootstrap ?? []) : list === 'compaction' ? (cfg.sectionsCompaction ?? []) : []
34
- return [...(cfg.sections ?? []), ...phase]
35
- }
36
-
37
- /**
38
- * 某个阶段部分当前的注入身份(含未保存草稿)。
39
- *
40
- * `names` = 本部分可见的注入段名(本阶段专属 + 跨阶段的 always)。post 视图来自
41
- * 「上次保存」的服务端结果,删除只改草稿,所以 post 独有段必须有 names 背书才
42
- * 能显示 —— 否则刚删掉的自定义段会以「系统段」复活(身份只认 custom 标记,条目
43
- * 没了就被判成系统),再经一次重排就被写成 custom:false 空文本,用户填的内容被
44
- * 抹平。`custom` 只收真正的自定义段,`order` 只收本阶段的草稿序。
45
- *
46
- * `text` = 本阶段生效的用户文本:自定义段自带的文本,以及**系统段在本阶段被
47
- * 替换后的文本**(非空即替换)。系统段的文本必须按阶段存 —— 注入条目本来就带
48
- * `phase`,服务端按阶段筛选,所以三个阶段各改各的文本互不影响;旧的全局
49
- * `replace` 字典一份文本对三个阶段同时生效,已不承担界面编辑。
50
- */
51
- export function injectedAt(cfg, key) {
52
- const phase = injectPhaseOf(key)
53
- const names = new Set()
54
- const custom = new Set()
55
- const text = new Map()
56
- const order = new Map()
57
- for (const item of cfg.inject ?? []) {
58
- if (item === null || typeof item !== 'object' || typeof item.name !== 'string') continue
59
- const itemPhase = item.phase ?? 'always'
60
- if (itemPhase === phase) order.set(item.name, item.order ?? 0)
61
- if (!acceptsInjectFor(key, itemPhase)) continue
62
- names.add(item.name)
63
- if (item.text && !text.has(item.name)) text.set(item.name, item.text)
64
- if (item.custom === true) custom.add(item.name)
65
- }
66
- return { phase, names, custom, text, order }
67
- }
68
-
69
- /**
70
- * 屏蔽/恢复一个段(当前阶段语义):返回需要写盘的字段补丁。
71
- * - 屏蔽:该阶段名单加入(常驻期直接加入全局 sections)。
72
- * - 恢复:从该阶段名单与全局名单同时移除(union 语义 —— 否则仍会被另一
73
- * 份名单挡住,恢复不生效)。
74
- * 名单无变化时不产出字段(空补丁 = 不写盘),避免把继承值冻结成无意义的
75
- * 空数组覆盖。
76
- */
77
- export function blockPatch(cfg, key, name, blocked) {
78
- const list = sectionListOf(key)
79
- const g = (cfg.sections ?? []).slice()
80
- const gi = g.indexOf(name)
81
- const patch = {}
82
- if (list === 'global') {
83
- if (blocked) { if (gi < 0) { g.push(name); patch.sections = g } }
84
- else if (gi >= 0) { g.splice(gi, 1); patch.sections = g }
85
- return patch
86
- }
87
- const field = list === 'bootstrap' ? 'sectionsBootstrap' : 'sectionsCompaction'
88
- const phase = (cfg[field] ?? []).slice()
89
- const pi = phase.indexOf(name)
90
- if (blocked) {
91
- if (pi < 0) { phase.push(name); patch[field] = phase }
92
- } else {
93
- if (pi >= 0) { phase.splice(pi, 1); patch[field] = phase }
94
- if (gi >= 0) { g.splice(gi, 1); patch.sections = g }
95
- }
96
- return patch
97
- }
98
-
99
- /**
100
- * 重排/插入一个阶段的有序行列表:把 dragName 从列表中移除后,插入到
101
- * targetName 行之上(pos='above')或之下(pos='below');返回新列表。
102
- * 拖入的新段由调用方以 newRow 提供,插入后保留在列表中(其余行保持原样)。
103
- */
104
- export function reorderInsert(rows, dragName, targetName, pos, newRow = null) {
105
- const base = rows.filter((row) => row.name !== dragName)
106
- const idx = base.findIndex((row) => row.name === targetName)
107
- if (idx < 0) return null
108
- const insertAt = Math.max(0, Math.min(pos === 'above' ? idx : idx + 1, base.length))
109
- const entry = newRow ?? rows.find((row) => row.name === dragName)
110
- if (!entry) return null
111
- base.splice(insertAt, 0, entry)
112
- return base
113
- }
114
-
115
- /**
116
- * 持久化一个阶段的有序列表(v1 persistOrder 的逐阶段版):把 rows(有序行)
117
- * 写成该阶段的注入条目,order = 连续整数(0,1,2,…)——「虚拟 order 决定注入
118
- * 顺序」;文本优先级 = 本阶段的用户替换文本(row.override)→ 自定义段自带的
119
- * 文本(row.text)→ 空文本(= 仅 order 覆盖,服务端保留原文)。三者缺一不可:
120
- * 重排走的就是这个函数,漏掉 override 会让一次拖动把用户在该阶段改的文本抹平。
121
- * 其它阶段(含 always)的注入条目原样保留。
122
- */
123
- export function phaseInjectEntries(cfg, key, rows) {
124
- const phase = injectPhaseOf(key)
125
- const others = (cfg.inject ?? []).filter((item) => (item.phase ?? 'always') !== phase)
126
- const entries = rows.map((row, i) => ({
127
- name: row.name,
128
- order: i,
129
- text: row.override || (row.custom ? (row.text ?? '') : ''),
130
- phase,
131
- custom: row.custom,
132
- }))
133
- return [...others, ...entries]
1
+ /**
2
+ * 提示词段面板的纯状态逻辑(无 React / 无 IO)。
3
+ * 与 src/client/SectionsTab.tsx 共享同一份实现(浏览器里随 client bundle
4
+ * 打包;node --test 直接 import 本文件跑单测),保证「测试即上线代码」。
5
+ *
6
+ * 阶段模型:引导期(bootstrap)/ 常驻期(active)/ 压缩受控期(compaction)
7
+ * 恒定全部显示(预设没有某个阶段时该部分就是空的),各自拥有独立的注入
8
+ * 名单与 order 空间;屏蔽按「每阶段独立名单」写回(引导期 →
9
+ * sectionsBootstrap,压缩受控期 → sectionsCompaction,常驻期 → 全局 sections)。
10
+ * 入参统一是名义阶段键,不再有「同形折叠组」这种把引导期当常驻期用的形态。
11
+ */
12
+
13
+ /** 名义阶段键 → 屏蔽名单写回目标(常驻期用全局 sections)。 */
14
+ export function sectionListOf(key) {
15
+ return key === 'bootstrap' ? 'bootstrap' : key === 'compaction' ? 'compaction' : 'global'
16
+ }
17
+
18
+ /** 名义阶段键 → 注入阶段:压缩受控期是独立注入阶段(compaction)。 */
19
+ export function injectPhaseOf(key) {
20
+ return key === 'bootstrap' ? 'bootstrap' : key === 'active' ? 'active' : 'compaction'
21
+ }
22
+
23
+ /** 某注入段出现在哪些阶段部分:always 全阶段;bootstrap/compaction/active
24
+ * 仅各自对应的阶段(与服务端 filterInjectByPhase 的三态互相独立一致)。 */
25
+ export function acceptsInjectFor(key, phase) {
26
+ if (phase === 'always') return true
27
+ return phase === key
28
+ }
29
+
30
+ /** 该阶段自己的屏蔽名单(三态互相独立:引导期 sectionsBootstrap /
31
+ * 压缩受控期 sectionsCompaction / 常驻期 sections,互不继承、互不影响)。 */
32
+ export function deniedNames(cfg, key) {
33
+ const list = sectionListOf(key)
34
+ return list === 'bootstrap' ? (cfg.sectionsBootstrap ?? [])
35
+ : list === 'compaction' ? (cfg.sectionsCompaction ?? [])
36
+ : (cfg.sections ?? [])
37
+ }
38
+
39
+ /**
40
+ * 某个阶段部分当前的注入身份(含未保存草稿)。
41
+ *
42
+ * `names` = 本部分可见的注入段名(本阶段专属 + 跨阶段的 always)。post 视图来自
43
+ * 「上次保存」的服务端结果,删除只改草稿,所以 post 独有段必须有 names 背书才
44
+ * 能显示 —— 否则刚删掉的自定义段会以「系统段」复活(身份只认 custom 标记,条目
45
+ * 没了就被判成系统),再经一次重排就被写成 custom:false 空文本,用户填的内容被
46
+ * 抹平。`custom` 只收真正的自定义段,`order` 只收本阶段的草稿序。
47
+ *
48
+ * `text` = 本阶段生效的用户文本:自定义段自带的文本,以及**系统段在本阶段被
49
+ * 替换后的文本**(非空即替换)。系统段的文本必须按阶段存 —— 注入条目本来就带
50
+ * `phase`,服务端按阶段筛选,所以三个阶段各改各的文本互不影响;旧的全局
51
+ * `replace` 字典一份文本对三个阶段同时生效,已不承担界面编辑。
52
+ */
53
+ export function injectedAt(cfg, key) {
54
+ const phase = injectPhaseOf(key)
55
+ const names = new Set()
56
+ const custom = new Set()
57
+ const text = new Map()
58
+ const order = new Map()
59
+ for (const item of cfg.inject ?? []) {
60
+ if (item === null || typeof item !== 'object' || typeof item.name !== 'string') continue
61
+ const itemPhase = item.phase ?? 'always'
62
+ if (itemPhase === phase) order.set(item.name, item.order ?? 0)
63
+ if (!acceptsInjectFor(key, itemPhase)) continue
64
+ names.add(item.name)
65
+ if (item.text && !text.has(item.name)) text.set(item.name, item.text)
66
+ if (item.custom === true) custom.add(item.name)
67
+ }
68
+ return { phase, names, custom, text, order }
69
+ }
70
+
71
+ /**
72
+ * 屏蔽/恢复一个段(当前阶段语义):返回需要写盘的字段补丁。
73
+ * 三态名单互相独立:只读写本阶段自己的名单(引导期 → sectionsBootstrap,
74
+ * 压缩受控期 → sectionsCompaction,常驻期 → sections),屏蔽 / 恢复都绝不
75
+ * 波及其它阶段的名单 —— 一个阶段的屏蔽不再连带屏蔽另一个阶段。
76
+ * 名单无变化时不产出字段(空补丁 = 不写盘),避免把继承值冻结成无意义的
77
+ * 空数组覆盖。
78
+ */
79
+ export function blockPatch(cfg, key, name, blocked) {
80
+ const list = sectionListOf(key)
81
+ const field = list === 'bootstrap' ? 'sectionsBootstrap' : list === 'compaction' ? 'sectionsCompaction' : 'sections'
82
+ const cur = (cfg[field] ?? []).slice()
83
+ const i = cur.indexOf(name)
84
+ if (blocked) {
85
+ if (i >= 0) return {}
86
+ cur.push(name)
87
+ } else {
88
+ if (i < 0) return {}
89
+ cur.splice(i, 1)
90
+ }
91
+ return { [field]: cur }
92
+ }
93
+
94
+ /**
95
+ * 重排/插入一个阶段的有序行列表:把 dragName 从列表中移除后,插入到
96
+ * targetName 行之上(pos='above')或之下(pos='below');返回新列表。
97
+ * 拖入的新段由调用方以 newRow 提供,插入后保留在列表中(其余行保持原样)。
98
+ */
99
+ export function reorderInsert(rows, dragName, targetName, pos, newRow = null) {
100
+ const base = rows.filter((row) => row.name !== dragName)
101
+ const idx = base.findIndex((row) => row.name === targetName)
102
+ if (idx < 0) return null
103
+ const insertAt = Math.max(0, Math.min(pos === 'above' ? idx : idx + 1, base.length))
104
+ const entry = newRow ?? rows.find((row) => row.name === dragName)
105
+ if (!entry) return null
106
+ base.splice(insertAt, 0, entry)
107
+ return base
108
+ }
109
+
110
+ /**
111
+ * 持久化一个阶段的有序列表(v1 persistOrder 的逐阶段版):把 rows(有序行)
112
+ * 写成该阶段的注入条目,order = 连续整数(0,1,2,…)——「虚拟 order 决定注入
113
+ * 顺序」;文本优先级 = 本阶段的用户替换文本(row.override)→ 自定义段自带的
114
+ * 文本(row.text)→ 空文本(= 仅 order 覆盖,服务端保留原文)。三者缺一不可:
115
+ * 重排走的就是这个函数,漏掉 override 会让一次拖动把用户在该阶段改的文本抹平。
116
+ * 其它阶段(含 always)的注入条目原样保留。
117
+ */
118
+ export function phaseInjectEntries(cfg, key, rows) {
119
+ const phase = injectPhaseOf(key)
120
+ const others = (cfg.inject ?? []).filter((item) => (item.phase ?? 'always') !== phase)
121
+ const entries = rows.map((row, i) => ({
122
+ name: row.name,
123
+ order: i,
124
+ text: row.override || (row.custom ? (row.text ?? '') : ''),
125
+ phase,
126
+ custom: row.custom,
127
+ }))
128
+ return [...others, ...entries]
134
129
  }
package/lib/store.js CHANGED
@@ -1,124 +1,124 @@
1
- /**
2
- * 插件自有配置存储 —— `~/.dsh/prompt-customizer/config.yaml`。
3
- *
4
- * 背景:宿主 SettingsProvider 把所有插件命名空间都存在主文档
5
- * (settings.yaml)里,插件无法选择存储位置;本插件改为自带文件存储,
6
- * 让主文档不再被本插件的配置膨胀。
7
- *
8
- * 语义:
9
- * - 新文件是唯一权威(读写都走这里)。
10
- * - 懒加载:每次读取做 stat,mtime 变了才重新解析——外部手工编辑在下次
11
- * 读取时自然生效,不需要 fs.watch。
12
- * - 原子写:先写同目录临时文件再 rename。
13
- * - last-good:文件损坏或 schema 校验失败时,沿用上一次合法值并告警一次,
14
- * 绝不让一个坏文件拖垮装配瀑布流。
15
- */
16
-
17
- import fs from 'node:fs'
18
- import path from 'node:path'
19
- import { parse, stringify } from 'yaml'
20
-
21
- /** 新 yml 文件的头部注释(让手工编辑者知道这是什么)。 */
22
- const HEADER = '# dsh-prompt-customizer 配置(唯一权威)。\n' +
23
- '# 手工编辑即时生效(下次读取时);删除本文件后重启会从旧版 settings.yaml 迁移。\n'
24
-
25
- /** 读主文档(旧版 settings.yaml)里本插件的旧段;主文档缺失或段不存在返回 undefined。 */
26
- export function readLegacySection(masterPath, ns) {
27
- if (!masterPath) return undefined
28
- let doc
29
- try {
30
- doc = parse(fs.readFileSync(masterPath, 'utf8'))
31
- } catch {
32
- return undefined
33
- }
34
- const section = doc?.[ns]
35
- return section !== null && typeof section === 'object' && !Array.isArray(section) && Object.keys(section).length > 0
36
- ? section
37
- : undefined
38
- }
39
-
40
- /** 创建一个配置存储实例。schema 用于在读取时补默认值并校验;header 可覆盖
41
- * 文件头注释(派生缓存类文件需要说明自己不是配置)。 */
42
- export function createConfigStore({ file, schema, warn, header = HEADER } = {}) {
43
- if (!file) throw new TypeError('createConfigStore: file is required')
44
- /** { stamp, raw, resolved, error } — stamp 为 undefined 表示文件不存在。 */
45
- let cache
46
- let warned = false
47
- const warnOnce = (message) => {
48
- if (warned) return
49
- warned = true
50
- try { warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
51
- }
52
-
53
- /** 用 schema 解析(补默认值 + 校验);失败返回 { value: null, error }。 */
54
- const resolve = (raw) => {
55
- if (schema === undefined) return { value: raw ?? {}, error: undefined }
56
- try { return { value: schema(raw ?? {}), error: undefined } } catch (error) { return { value: null, error } }
57
- }
58
-
59
- /** stat 驱动的懒加载;返回完整缓存条目。 */
60
- const load = () => {
61
- let stamp
62
- try { stamp = fs.statSync(file).mtimeMs } catch { stamp = undefined }
63
- if (cache && cache.stamp === stamp) return cache
64
- if (stamp === undefined) {
65
- cache = { stamp, raw: undefined, ...resolve(undefined) }
66
- return cache
67
- }
68
- let raw
69
- let error
70
- try {
71
- raw = parse(fs.readFileSync(file, 'utf8'))
72
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
73
- raw = undefined
74
- error = new Error('config.yaml 的顶层必须是映射')
75
- }
76
- } catch (e) {
77
- raw = undefined
78
- error = e
79
- }
80
- const r = raw === undefined ? { value: null, error } : resolve(raw)
81
- // raw 保留磁盘上的真实解析结果(即使 schema 失败)——写合并不应静默丢字段。
82
- cache = { stamp, raw, ...r }
83
- // 任何一次成功解析(含写入后的校验)都刷新 last-good。
84
- if (r.value !== null) lastGood = r.value
85
- return cache
86
- }
87
-
88
- /** 上一次 schema 校验通过的解析结果(坏文件时的 last-good 回落)。 */
89
- let lastGood
90
-
91
- return {
92
- /** 当前存储的原始用户段(未补默认值);无文件时 undefined。 */
93
- raw() {
94
- return load().raw
95
- },
96
-
97
- /** 解析后的生效配置(默认值已补;坏文件回落 last-good,再回落纯默认值)。 */
98
- readResolved() {
99
- const entry = load()
100
- if (entry.value !== null) return entry.value
101
- warnOnce(`prompt-customizer: 配置文件不可用,回落到最近一次合法配置:${String(entry.error && entry.error.message ? entry.error.message : entry.error)}`)
102
- return lastGood ?? resolve(undefined).value
103
- },
104
-
105
- /** 原子写入一个完整用户段;写入后立刻刷新缓存。 */
106
- writeSection(section) {
107
- fs.mkdirSync(path.dirname(file), { recursive: true })
108
- const tmp = `${file}.${process.pid}.tmp`
109
- fs.writeFileSync(tmp, header + stringify(section ?? {}))
110
- fs.renameSync(tmp, file)
111
- cache = undefined
112
- return load()
113
- },
114
-
115
- /** 写单个字段(值 undefined 表示删除);基于当前权威段合并。 */
116
- setField(field, value) {
117
- const base = this.raw()
118
- const section = { ...(base ?? {}) }
119
- if (value === undefined) delete section[field]
120
- else section[field] = value
121
- return this.writeSection(section)
122
- },
123
- }
124
- }
1
+ /**
2
+ * 插件自有配置存储 —— `~/.dsh/prompt-customizer/config.yaml`。
3
+ *
4
+ * 背景:宿主 SettingsProvider 把所有插件命名空间都存在主文档
5
+ * (settings.yaml)里,插件无法选择存储位置;本插件改为自带文件存储,
6
+ * 让主文档不再被本插件的配置膨胀。
7
+ *
8
+ * 语义:
9
+ * - 新文件是唯一权威(读写都走这里)。
10
+ * - 懒加载:每次读取做 stat,mtime 变了才重新解析——外部手工编辑在下次
11
+ * 读取时自然生效,不需要 fs.watch。
12
+ * - 原子写:先写同目录临时文件再 rename。
13
+ * - last-good:文件损坏或 schema 校验失败时,沿用上一次合法值并告警一次,
14
+ * 绝不让一个坏文件拖垮装配瀑布流。
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { parse, stringify } from 'yaml'
20
+
21
+ /** 新 yml 文件的头部注释(让手工编辑者知道这是什么)。 */
22
+ const HEADER = '# dsh-prompt-customizer 配置(唯一权威)。\n' +
23
+ '# 手工编辑即时生效(下次读取时);删除本文件后重启会从旧版 settings.yaml 迁移。\n'
24
+
25
+ /** 读主文档(旧版 settings.yaml)里本插件的旧段;主文档缺失或段不存在返回 undefined。 */
26
+ export function readLegacySection(masterPath, ns) {
27
+ if (!masterPath) return undefined
28
+ let doc
29
+ try {
30
+ doc = parse(fs.readFileSync(masterPath, 'utf8'))
31
+ } catch {
32
+ return undefined
33
+ }
34
+ const section = doc?.[ns]
35
+ return section !== null && typeof section === 'object' && !Array.isArray(section) && Object.keys(section).length > 0
36
+ ? section
37
+ : undefined
38
+ }
39
+
40
+ /** 创建一个配置存储实例。schema 用于在读取时补默认值并校验;header 可覆盖
41
+ * 文件头注释(派生缓存类文件需要说明自己不是配置)。 */
42
+ export function createConfigStore({ file, schema, warn, header = HEADER } = {}) {
43
+ if (!file) throw new TypeError('createConfigStore: file is required')
44
+ /** { stamp, raw, resolved, error } — stamp 为 undefined 表示文件不存在。 */
45
+ let cache
46
+ let warned = false
47
+ const warnOnce = (message) => {
48
+ if (warned) return
49
+ warned = true
50
+ try { warn?.(message) } catch { /* logger 缺席只是不再告警 */ }
51
+ }
52
+
53
+ /** 用 schema 解析(补默认值 + 校验);失败返回 { value: null, error }。 */
54
+ const resolve = (raw) => {
55
+ if (schema === undefined) return { value: raw ?? {}, error: undefined }
56
+ try { return { value: schema(raw ?? {}), error: undefined } } catch (error) { return { value: null, error } }
57
+ }
58
+
59
+ /** stat 驱动的懒加载;返回完整缓存条目。 */
60
+ const load = () => {
61
+ let stamp
62
+ try { stamp = fs.statSync(file).mtimeMs } catch { stamp = undefined }
63
+ if (cache && cache.stamp === stamp) return cache
64
+ if (stamp === undefined) {
65
+ cache = { stamp, raw: undefined, ...resolve(undefined) }
66
+ return cache
67
+ }
68
+ let raw
69
+ let error
70
+ try {
71
+ raw = parse(fs.readFileSync(file, 'utf8'))
72
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
73
+ raw = undefined
74
+ error = new Error('config.yaml 的顶层必须是映射')
75
+ }
76
+ } catch (e) {
77
+ raw = undefined
78
+ error = e
79
+ }
80
+ const r = raw === undefined ? { value: null, error } : resolve(raw)
81
+ // raw 保留磁盘上的真实解析结果(即使 schema 失败)——写合并不应静默丢字段。
82
+ cache = { stamp, raw, ...r }
83
+ // 任何一次成功解析(含写入后的校验)都刷新 last-good。
84
+ if (r.value !== null) lastGood = r.value
85
+ return cache
86
+ }
87
+
88
+ /** 上一次 schema 校验通过的解析结果(坏文件时的 last-good 回落)。 */
89
+ let lastGood
90
+
91
+ return {
92
+ /** 当前存储的原始用户段(未补默认值);无文件时 undefined。 */
93
+ raw() {
94
+ return load().raw
95
+ },
96
+
97
+ /** 解析后的生效配置(默认值已补;坏文件回落 last-good,再回落纯默认值)。 */
98
+ readResolved() {
99
+ const entry = load()
100
+ if (entry.value !== null) return entry.value
101
+ warnOnce(`prompt-customizer: 配置文件不可用,回落到最近一次合法配置:${String(entry.error && entry.error.message ? entry.error.message : entry.error)}`)
102
+ return lastGood ?? resolve(undefined).value
103
+ },
104
+
105
+ /** 原子写入一个完整用户段;写入后立刻刷新缓存。 */
106
+ writeSection(section) {
107
+ fs.mkdirSync(path.dirname(file), { recursive: true })
108
+ const tmp = `${file}.${process.pid}.tmp`
109
+ fs.writeFileSync(tmp, header + stringify(section ?? {}))
110
+ fs.renameSync(tmp, file)
111
+ cache = undefined
112
+ return load()
113
+ },
114
+
115
+ /** 写单个字段(值 undefined 表示删除);基于当前权威段合并。 */
116
+ setField(field, value) {
117
+ const base = this.raw()
118
+ const section = { ...(base ?? {}) }
119
+ if (value === undefined) delete section[field]
120
+ else section[field] = value
121
+ return this.writeSection(section)
122
+ },
123
+ }
124
+ }