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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Epoch-aware 晋级 tracker —— 行为对标 agent-presets 实验预设里的
3
+ * compaction-epoch.mjs:晋级由 durable 会话事件推导(默认首个
4
+ * `tool/call` 或 `assistant/message`),compaction 之后复位(首个压缩后
5
+ * 请求回到 bootstrap 态,直到新的晋级信号),subagent 视为恒已晋级。
6
+ *
7
+ * 状态按 session.id 记忆,冷会话扫一遍 durable log 后 O(1),因此 resume
8
+ * / reload 重建出相同的阶段。
9
+ */
10
+
11
+ /** 默认的晋级信号事件。 */
12
+ const DEFAULT_PROMOTE_EVENTS = ['tool/call', 'assistant/message']
13
+
14
+ /** 构建一个晋级 tracker;promoteEvents 可换成单事件数组。 */
15
+ export function createPromotion(promoteEvents = DEFAULT_PROMOTE_EVENTS) {
16
+ const promote = new Set(promoteEvents)
17
+ /** sessionId -> { boundary, promoted } */
18
+ const state = new Map()
19
+
20
+ /** 从 durable log 冷扫描。 */
21
+ const scan = (session) => {
22
+ let boundary = -1
23
+ let promoted = false
24
+ for (const event of session.events ?? []) {
25
+ const seq = event.seq ?? 0 // 没有 seq 的事件视为边界之后
26
+ if (event.type === 'compaction/end') {
27
+ boundary = seq
28
+ promoted = false
29
+ continue
30
+ }
31
+ if (promote.has(event.type) && seq > boundary) promoted = true
32
+ }
33
+ const entry = { boundary, promoted }
34
+ state.set(session.id, entry)
35
+ return entry
36
+ }
37
+
38
+ return {
39
+ /**
40
+ * 当前 agent 的阶段。agent 缺席(纯清单读取等)或子代理一律已晋级;
41
+ * ponytail: 只覆盖 compaction 边界这一种 epoch,未跟踪 fork 语义。
42
+ * 升级路径:参照 anchored-standard 的 fork-aware 边界扩展 scan。
43
+ */
44
+ status(agent) {
45
+ const session = agent?.session
46
+ if (!session) return { boundary: -1, promoted: true }
47
+ if ((session.header?.delegationDepth ?? 0) > 0) return { boundary: -1, promoted: true }
48
+ return state.get(session.id) ?? scan(session)
49
+ },
50
+
51
+ /** 增量喂入 `session/event`,避免每次装配全量扫描。 */
52
+ observe(session, event) {
53
+ const entry = state.get(session.id)
54
+ if (entry === undefined) return
55
+ const seq = event.seq ?? 0
56
+ if (event.type === 'compaction/end') {
57
+ state.set(session.id, { boundary: seq, promoted: false })
58
+ return
59
+ }
60
+ if (promote.has(event.type) && seq > entry.boundary && !entry.promoted) {
61
+ state.set(session.id, { ...entry, promoted: true })
62
+ }
63
+ },
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 解析一个会话所属的 agent 预设 id:header 记录创建时值,
69
+ * `agent-preset/selected` 事件记录空白期切换后的值,后者优先。
70
+ */
71
+ export function presetOfSession(session) {
72
+ if (!session) return undefined
73
+ let preset = typeof session.header?.agentPreset === 'string' ? session.header.agentPreset : undefined
74
+ for (const event of session.events ?? []) {
75
+ if (event.type === 'agent-preset/selected' && typeof event.data?.agentPreset === 'string') {
76
+ preset = event.data.agentPreset
77
+ }
78
+ }
79
+ return preset
80
+ }
package/lib/schema.js CHANGED
@@ -1,70 +1,115 @@
1
1
  import Schema from '@deepseek-ai/schemastery'
2
2
 
3
- /** Settings namespace for this plugin (lowercase kebab-case). */
3
+ /** 本插件的设置命名空间(小写 kebab-case)。 */
4
4
  export const NS = 'prompt-customizer'
5
5
 
6
6
  /**
7
- * The plugin's configuration schema, also used as the settings-namespace
8
- * schema. GUI edits write into this shape under `~/.dsh/settings.yaml`.
7
+ * 插件的配置 schema,同时作为设置命名空间的 schema。
8
+ * GUI 的编辑以这个形状写入 `~/.dsh/settings.yaml`。
9
9
  */
10
10
  export const Config = Schema.object({
11
- // Block these prompt sections by name (filtered out of the assembled system prompt).
11
+ // 按名称屏蔽这些提示词段(从装配后的系统提示词中过滤掉)。
12
12
  sections: Schema.array(Schema.string()).default([]),
13
- // Replace a section's text by name (original order kept).
13
+ // 每阶段独立段屏蔽(与 tools 阶段化对称):引导期 / 压缩受控期的额外
14
+ // 屏蔽名单,非空时在该阶段生效并优先于全局 sections;空列表回落全局。
15
+ sectionsBootstrap: Schema.array(Schema.string()).default([]),
16
+ sectionsCompaction: Schema.array(Schema.string()).default([]),
17
+ // 按名称替换某个段的文本(保持原顺序)。
14
18
  replace: Schema.dict(Schema.string()).default({}),
15
- // Inject / overwrite sections (by name + order + text). `custom` marks a
16
- // section this plugin generated (a hidden marker so it can be identified as
17
- // deletable, even across presets, without relying on name collisions).
19
+ // 注入 / 覆盖段(按 名称 + 顺序 + 文本)。`custom` 标记本插件生成的段
20
+ // (隐藏标记,用于识别其为可删除,跨预设依然成立,不依赖名字碰撞)。
21
+ // `phase` 控制阶段生效:`always` 恒定;`bootstrap` 只在会话未晋级时注入;
22
+ // `compaction` 只在「压缩后仍未晋级」时注入(独立于 bootstrap,拥有自己的
23
+ // order 空间);`active` 只在晋级后注入(晋级 = 首个 durable 的 tool/call
24
+ // 或 assistant/message;compaction 之后复位,subagent 视为已晋级)。
18
25
  inject: Schema.array(Schema.object({
19
26
  name: Schema.string().required(),
20
27
  order: Schema.number().default(120),
21
28
  text: Schema.string().required(),
29
+ phase: Schema.string().default('always'),
22
30
  custom: Schema.boolean().default(false),
23
31
  })).default([]),
24
- // Tool catalog filtering: exclude hides listed tools; include (when non-empty)
25
- // keeps only listed tools and wins over exclude.
32
+ // 工具目录过滤:只有 exclude 黑名单(列出的工具对模型隐藏)。三份名单对应三个
33
+ // 阶段,各管各的、互不继承:`exclude` 只在已晋级(常驻期)生效,`bootstrap`
34
+ // 只在未晋级(引导期)生效,`compaction` 只在「compaction 之后仍未晋级」的
35
+ // 阶段生效;空名单 = 该阶段什么都不隐藏。没有白名单语义 —— 见 lib/effective.js
36
+ // 的 applyToolFilter 注释。
26
37
  tools: Schema.object({
27
38
  exclude: Schema.array(Schema.string()).default([]),
28
- include: Schema.array(Schema.string()).default([]),
39
+ // 该阶段要「加回来」的工具:被该阶段裁掉、但注册表里仍有的,装配时查回
40
+ // schema 追加。注册表里没有的(别的预设独有)加不进来。
41
+ add: Schema.array(Schema.string()).default([]),
42
+ bootstrap: Schema.object({
43
+ exclude: Schema.array(Schema.string()).default([]),
44
+ add: Schema.array(Schema.string()).default([]),
45
+ }).default({}),
46
+ compaction: Schema.object({
47
+ exclude: Schema.array(Schema.string()).default([]),
48
+ add: Schema.array(Schema.string()).default([]),
49
+ }).default({}),
29
50
  }).default({}),
30
- // Per-agent customizations: byAgent.<agentPresetId> OVERRIDES the default
31
- // sections / replace / inject / tools for that specific agent preset. The
32
- // top-level fields act as the shared default applied to every other agent.
33
- // Each entry is a partial overlay; omitted fields inherit the default.
34
- byAgent: Schema.dict(Schema.object({
35
- sections: Schema.array(Schema.string()),
36
- replace: Schema.dict(Schema.string()),
37
- inject: Schema.array(Schema.object({
38
- name: Schema.string().required(),
39
- order: Schema.number().default(120),
40
- text: Schema.string().required(),
41
- custom: Schema.boolean().default(false),
42
- })),
43
- tools: Schema.object({
44
- exclude: Schema.array(Schema.string()),
45
- include: Schema.array(Schema.string()),
46
- }),
47
- })).default({}),
48
- // Presets: complete customization snapshots (full prompt + tool config).
51
+ // 预设:完整的定制快照(提示词 + 工具配置,含每阶段独立名单与阶段目录)。
49
52
  presets: Schema.array(Schema.object({
50
53
  id: Schema.string().required(),
51
54
  name: Schema.string().required(),
52
55
  data: Schema.object({
53
56
  sections: Schema.array(Schema.string()),
57
+ // 每阶段独立段屏蔽名单:缺省 = 旧文件,应用时保留当前值不抹掉。
58
+ sectionsBootstrap: Schema.array(Schema.string()),
59
+ sectionsCompaction: Schema.array(Schema.string()),
54
60
  replace: Schema.dict(Schema.string()),
55
- // Relative order: each entry records the section it should follow.
61
+ // 相对顺序:每个条目记录它应跟随的前一段,以及这段属于哪个阶段
62
+ // (缺省 always = 旧快照的单一全局序;同名不同 phase 的条目 = 该段在
63
+ // 多个阶段各有自己的位置)。
56
64
  order: Schema.array(Schema.object({
57
65
  name: Schema.string().required(),
58
66
  after: Schema.string(),
59
67
  text: Schema.string(),
60
68
  custom: Schema.boolean().default(false),
69
+ phase: Schema.string(),
61
70
  })),
62
71
  tools: Schema.object({
63
72
  exclude: Schema.array(Schema.string()),
64
- include: Schema.array(Schema.string()),
73
+ add: Schema.array(Schema.string()),
74
+ bootstrap: Schema.object({
75
+ exclude: Schema.array(Schema.string()),
76
+ add: Schema.array(Schema.string()),
77
+ }),
78
+ compaction: Schema.object({
79
+ exclude: Schema.array(Schema.string()),
80
+ add: Schema.array(Schema.string()),
81
+ }),
65
82
  }),
66
83
  }),
67
84
  })).default([]),
68
- // The id of the currently applied preset (only one active at a time).
85
+ // 当前已应用预设的 id(同一时间只有一个生效)。
69
86
  activePreset: Schema.string(),
87
+ // 按 agent 预设 id 的字段级覆盖:某个 agent 预设的 override 中,非空的
88
+ // 字段整体接管全局默认,空缺 / 空列表的字段继续回落全局。键是
89
+ // dsh-agent-presets 的预设目录名(即会话 header.agentPreset 记录的 id)。
90
+ overrides: Schema.dict(Schema.object({
91
+ sections: Schema.array(Schema.string()),
92
+ sectionsBootstrap: Schema.array(Schema.string()),
93
+ sectionsCompaction: Schema.array(Schema.string()),
94
+ replace: Schema.dict(Schema.string()),
95
+ inject: Schema.array(Schema.object({
96
+ name: Schema.string().required(),
97
+ order: Schema.number().default(120),
98
+ text: Schema.string().required(),
99
+ phase: Schema.string().default('always'),
100
+ custom: Schema.boolean().default(false),
101
+ })),
102
+ tools: Schema.object({
103
+ exclude: Schema.array(Schema.string()),
104
+ add: Schema.array(Schema.string()),
105
+ bootstrap: Schema.object({
106
+ exclude: Schema.array(Schema.string()),
107
+ add: Schema.array(Schema.string()),
108
+ }),
109
+ compaction: Schema.object({
110
+ exclude: Schema.array(Schema.string()),
111
+ add: Schema.array(Schema.string()),
112
+ }),
113
+ }),
114
+ })).default({}),
70
115
  })
@@ -0,0 +1,32 @@
1
+ /** sectionOps.mjs 的类型声明(仅供电类型检查;运行时代码在 .mjs 中)。 */
2
+
3
+ /** 名义阶段键:引导期 / 常驻期 / 压缩受控期(恒定全部显示)。 */
4
+ export type PhaseKey = 'bootstrap' | 'active' | 'compaction'
5
+
6
+ export function sectionListOf(key: PhaseKey): 'global' | 'bootstrap' | 'compaction'
7
+ export function injectPhaseOf(key: PhaseKey): 'bootstrap' | 'active' | 'compaction'
8
+ export function acceptsInjectFor(key: PhaseKey, phase: string): boolean
9
+ export function deniedNames(cfg: unknown, key: PhaseKey): string[]
10
+ /** 本部分当前的注入身份:phase 注入阶段、names 可见段名、custom 自定义段名、
11
+ * text 本阶段生效的用户文本(自定义段文本 + 系统段在本阶段的替换文本)、
12
+ * order 本阶段草稿序(都是 name → 值 的映射 / 集合)。 */
13
+ export function injectedAt(cfg: unknown, key: PhaseKey): {
14
+ phase: 'bootstrap' | 'active' | 'compaction'
15
+ names: Set<string>
16
+ custom: Set<string>
17
+ text: Map<string, string>
18
+ order: Map<string, number>
19
+ }
20
+ export function blockPatch(cfg: unknown, key: PhaseKey, name: string, blocked: boolean): Record<string, unknown>
21
+ export function reorderInsert<T extends object>(
22
+ rows: T[],
23
+ dragName: string,
24
+ targetName: string,
25
+ pos: 'above' | 'below',
26
+ newRow?: T | null,
27
+ ): T[] | null
28
+ export function phaseInjectEntries<T extends object>(
29
+ cfg: unknown,
30
+ key: PhaseKey,
31
+ rows: T[],
32
+ ): Array<Record<string, unknown>>
@@ -0,0 +1,134 @@
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]
134
+ }
package/lib/store.js ADDED
@@ -0,0 +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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-prompt-customizer",
3
- "description": "System-prompt & tool customizer for DeepSeek Harness with a settings-panel UI: block / replace / inject prompt sections by name, and hide tools from the model catalog by exclude/include. Config lives in the dsh settings document (namespace prompt-customizer).",
4
- "version": "0.2.7",
3
+ "description": "System-prompt & tool customizer for DeepSeek Harness with a settings-panel UI: block / replace / inject prompt sections by name, and hide tools from the model catalog by exclude/include. Config lives in the plugin's own ~/.dsh/prompt-customizer/config.yaml (auto-migrated once from the legacy settings.yaml section).",
4
+ "version": "0.3.1",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "main": "lib/index.js",
@@ -34,14 +34,16 @@
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": "^4.0.1",
37
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
37
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
38
38
  "react": "^18.2.0",
39
39
  "react-dom": "^18.2.0"
40
40
  },
41
41
  "dependencies": {
42
- "@deepseek-ai/schemastery": "^3.18.0"
42
+ "@deepseek-ai/schemastery": "^3.18.0",
43
+ "yaml": "^2.9.0"
43
44
  },
44
45
  "devDependencies": {
46
+ "@deepseek-ai/cordis": "4.0.1",
45
47
  "@types/react": "~18.3.1",
46
48
  "@types/react-dom": "~18.3.1",
47
49
  "react": "^18.3.1",
@@ -59,4 +61,4 @@
59
61
  "access": "public"
60
62
  },
61
63
  "license": "MIT"
62
- }
64
+ }