dsh-soul 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ // lib/workspace-scope.mjs — 工作区级人设覆盖(纯函数层)
2
+ //
3
+ // 职责:
4
+ // - 工作区覆盖表的校验与序列化(读侧一律当不可信输入)
5
+ // - `workspaceId → 人设名` 的解析
6
+ // - 与全局配置的**字段级部分覆盖**合并
7
+ //
8
+ // 四条设计前提,改本模块前请先读:
9
+ //
10
+ // 1. **工作区身份是 `workspaceId`,不是目录。** 宿主 `workspaceRegistry` 维护权威的
11
+ // `sessionId → workspaceId` 索引;目录只是**回退**依据(见 §2.2 的解析链)。
12
+ // 用目录当主键会在「工作区改名 / 目录移动」时丢掉覆盖,而用户完全无法察觉。
13
+ // 2. **`enabled` 绝不能被下层覆盖。** 它是全局总开关:`compilePrompt()` 首行就是
14
+ // `if (!config.enabled) return ''`。若某个工作区能把 enabled 改成 true,
15
+ // 「关掉插件」就不再是关掉 —— 用户会以为没关干净。这条由 ENVELOPE_ONLY
16
+ // 与断言共同钉住。
17
+ // 3. **部分覆盖,不是整份替换。** 与 `/api/soul/personas/use` 同一条规则:
18
+ // 只覆盖条目**显式声明过**的字段。同一个概念在两处有不同语义,用户就无法
19
+ // 用「点一下使用预设」来验证「这个工作区绑定生效了吗」。
20
+ // 4. **本模块只负责如实报告,不替调用方决定要不要回退** —— 与 lib/store.mjs、
21
+ // lib/history.mjs、lib/bindings.mjs 同一条约定,可被脚本独立加载测试。
22
+ //
23
+ // 为什么工作区覆盖放在 `soul-config.json` 而不是独立文件:
24
+ // 它是**配置**(低频、天然有上限、用户在意它的变更历史),与 Session 覆盖的
25
+ // **状态**性质不同(高频、无上限、会随会话消失)。状态才需要 sidecar。
26
+
27
+ import { PERSONA_FIELDS, PERSONA_NAME_MAX, sanitizePersonaName } from './config.mjs'
28
+ import { resolvePersona } from './personas.mjs'
29
+
30
+ // ==================== 契约常量 ====================
31
+
32
+ // 覆盖表格式版本。读到不认识的版本时**整表忽略**(而不是猜测字段语义):
33
+ // 升级后的键含义无法保证,宁可暂时不生效,也不能按错的方式解析出一堆假覆盖。
34
+ export const WORKSPACE_SCOPE_VERSION = 1
35
+
36
+ // 一个工作区能覆盖的字段白名单。
37
+ //
38
+ // 这就是 `PERSONA_FIELDS`:工作区覆盖表达的是「这个项目要什么样的人设」,
39
+ // 与人设预设能表达的完全一致。刻意**不含** `enabled`(见前提 2)、
40
+ // 也不含 `historyMaxEntries` / `trail*`(那些是全局设施,不是项目偏好)。
41
+ //
42
+ // 不导出 `PERSONA_FIELDS` 的引用而是当场列出,是为了让白名单在文件里**可读**:
43
+ // 一个 `[...PERSONA_FIELDS]` 会让「工作区到底能改什么」只能靠跳转才能回答。
44
+ export const WORKSPACE_OVERRIDE_FIELDS = Object.freeze([
45
+ 'style',
46
+ 'headingLists',
47
+ 'emoji',
48
+ 'tables',
49
+ 'replyLength',
50
+ 'language',
51
+ 'customInstructions'
52
+ ])
53
+
54
+ // 永远由全局决定、任何作用域都不能覆盖的字段。
55
+ export const ENVELOPE_ONLY_FIELDS = Object.freeze(['enabled'])
56
+
57
+ // 覆盖项里的**引用字段**:这个工作区当初绑的是哪个预设名。
58
+ //
59
+ // 为什么必须存:覆盖项本身只存展开后的字段值。一旦预设被改名或删除,覆盖仍然生效
60
+ // (值还在),但界面再也说不出「它原本指向谁」—— 用户无从判断这是有意保留还是遗留垃圾。
61
+ // 存下这个名字,界面才能如实报告「人设『极简主义者』已不存在」。
62
+ //
63
+ // 用双下划线前缀:与配置字段(都是驼峰)不会撞名,日志里也一眼可辨。
64
+ // 它**不参与合并**(mergeWorkspaceOverride 跳过它),也不是可覆盖字段。
65
+ export const WORKSPACE_PERSONA_REF = '__persona'
66
+
67
+ // 一个工作区一条覆盖。上限按「用户实际会建多少个工作区」定:
68
+ // 宿主的工作区是用户手动添加的,几十个已属异常;超出时如实报告而不是静默截断。
69
+ export const MAX_WORKSPACE_SCOPES = 200
70
+
71
+ // workspaceId 的长度上限。宿主用 `WorkspaceId` 品牌类型,实测形如短字符串;
72
+ // 给一个宽松上限只为拦住把整个路径塞进 key 这类错误用法。
73
+ export const MAX_WORKSPACE_ID_LENGTH = 128
74
+
75
+ // ==================== 校验 ====================
76
+
77
+ /**
78
+ * 判断一个值能否当作 workspaceId 的键。
79
+ *
80
+ * 不接受:
81
+ * - 非字符串 / 空串
82
+ * - 含路径分隔符(`\` 或 `/`)—— 那是「把目录当身份」的典型错误,必须在入口拒绝,
83
+ * 否则同一目录会因为写法不同而出现两条互不相干的覆盖
84
+ * - 超长
85
+ *
86
+ * @param {unknown} raw
87
+ * @returns {boolean}
88
+ */
89
+ export function isWorkspaceKey(raw) {
90
+ if (typeof raw !== 'string') return false
91
+ const key = raw.trim()
92
+ if (key === '') return false
93
+ if (key.length > MAX_WORKSPACE_ID_LENGTH) return false
94
+ if (key.includes('\\') || key.includes('/')) return false
95
+ return true
96
+ }
97
+
98
+ /**
99
+ * 覆盖项是否为空(空对象表示「这个工作区不覆盖任何字段」,等同于没有条目)。
100
+ *
101
+ * **引用字段不算内容**:一条只剩 `__persona` 的覆盖说明「当初绑过、但字段已全部失效」——
102
+ * 它不该被当成有效覆盖(那会挡住目录回退层,给出一个没人想要的空覆盖)。
103
+ *
104
+ * @param {unknown} entry
105
+ * @returns {boolean}
106
+ */
107
+ export function isEmptyOverride(entry) {
108
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return true
109
+ return Object.keys(entry).filter((key) => key !== WORKSPACE_PERSONA_REF).length === 0
110
+ }
111
+
112
+ /**
113
+ * 校验单个工作区覆盖项。读侧一律当不可信输入。
114
+ *
115
+ * 返回值**不抛错**:与 lib/bindings.mjs 的 `sanitizeBindings` 同一约定 ——
116
+ * 一条坏数据不该让整张表失效,调用方需要知道「哪几条被丢掉了」才能如实提示。
117
+ *
118
+ * @param {unknown} raw
119
+ * @returns {{ override: Record<string, unknown> | null, dropped: string[] }}
120
+ * override 为 null 表示整条不可用;dropped 列出被忽略的字段名(便于诊断)
121
+ */
122
+ export function sanitizeWorkspaceOverride(raw) {
123
+ const dropped = []
124
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
125
+ return { override: null, dropped }
126
+ }
127
+
128
+ const override = {}
129
+ for (const [key, value] of Object.entries(raw)) {
130
+ // 引用字段单独放行:它不是"可覆盖的字段",而是「当初绑的是谁」的记录,
131
+ // 供界面报告失效引用。不进 dropped(它不是被丢弃的字段)。
132
+ if (key === WORKSPACE_PERSONA_REF) {
133
+ if (typeof value === 'string' && value.trim() !== '') override[key] = value.trim()
134
+ continue
135
+ }
136
+ if (!WORKSPACE_OVERRIDE_FIELDS.includes(key)) {
137
+ // 包封字段(enabled)与未知字段一律丢弃:前者是越界,后者是无法理解的语义。
138
+ // 两者都必须**出现在 dropped 里**,否则「我明明写了却没生效」无从解释。
139
+ dropped.push(key)
140
+ continue
141
+ }
142
+ // 类型与取值交给 sanitizeConfig 统一校验(下面 pickPersonaValues + sanitizeConfig
143
+ // 走的是同一条路径),这里只做字段白名单筛选。
144
+ override[key] = value
145
+ }
146
+
147
+ return { override: isEmptyOverride(override) ? null : override, dropped }
148
+ }
149
+
150
+ /**
151
+ * 校验整张工作区覆盖表。
152
+ *
153
+ * @param {unknown} raw 读盘得到的原始值
154
+ * @returns {{ scopes: Record<string, object>, dropped: number, warning: string | null }}
155
+ * - scopes:可用的 `workspaceId → override`
156
+ * - dropped:被丢弃的条目数(形状不对 / 键非法 / 覆盖为空)
157
+ * - warning:整表不可用的原因(版本不认识等),此时 scopes 为空对象
158
+ */
159
+ export function sanitizeWorkspaceScopes(raw) {
160
+ const empty = { scopes: {}, dropped: 0, warning: null }
161
+ if (raw === undefined || raw === null) return empty
162
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
163
+ return { scopes: {}, dropped: 0, warning: '工作区覆盖表必须是一个对象' }
164
+ }
165
+
166
+ // 版本:不认识就整表忽略。这里刻意不「尽力解析」—— 升级后的字段语义无法保证,
167
+ // 按错的语义解析出一堆假覆盖,比暂时失效更难排查。
168
+ const version = raw.version
169
+ if (version !== undefined && version !== WORKSPACE_SCOPE_VERSION) {
170
+ return {
171
+ scopes: {},
172
+ dropped: 0,
173
+ warning: `工作区覆盖表版本 ${String(version)} 不认识(当前支持 ${WORKSPACE_SCOPE_VERSION}),已整表忽略`
174
+ }
175
+ }
176
+
177
+ const table = raw.scopes
178
+ if (table === undefined || table === null) return empty
179
+ if (typeof table !== 'object' || Array.isArray(table)) {
180
+ return { scopes: {}, dropped: 0, warning: '工作区覆盖表的 scopes 必须是一个对象' }
181
+ }
182
+
183
+ const scopes = {}
184
+ let dropped = 0
185
+ for (const [id, entry] of Object.entries(table)) {
186
+ if (!isWorkspaceKey(id)) { dropped++; continue }
187
+ const { override } = sanitizeWorkspaceOverride(entry)
188
+ if (override === null) { dropped++; continue }
189
+ scopes[id] = override
190
+ }
191
+
192
+ // 超上限时**如实报告**而不是静默截断:截断意味着「有些工作区的设置莫名不生效」,
193
+ // 那正是最不该静默的一类失败。
194
+ const keys = Object.keys(scopes)
195
+ if (keys.length > MAX_WORKSPACE_SCOPES) {
196
+ return {
197
+ scopes: {},
198
+ dropped: keys.length,
199
+ warning: `工作区覆盖表含 ${keys.length} 条,超过上限 ${MAX_WORKSPACE_SCOPES},已整表忽略`
200
+ }
201
+ }
202
+
203
+ return { scopes, dropped, warning: null }
204
+ }
205
+
206
+ /**
207
+ * 序列化工作区覆盖表(写盘用)。
208
+ * @param {Record<string, object>} scopes
209
+ * @returns {{ version: number, scopes: Record<string, object> }}
210
+ */
211
+ export function serializeWorkspaceScopes(scopes) {
212
+ return { version: WORKSPACE_SCOPE_VERSION, scopes: { ...scopes } }
213
+ }
214
+
215
+ // ==================== 解析与合并 ====================
216
+
217
+ /**
218
+ * 从覆盖表里取出某个工作区的覆盖项。
219
+ *
220
+ * 返回 null 表示「这个工作区没有自定义人设」—— 与「有覆盖但字段全空」是同一件事,
221
+ * 因为空覆盖在 sanitize 阶段就被归一成 null 了。
222
+ *
223
+ * @param {string | null | undefined} workspaceId
224
+ * @param {Record<string, object>} scopes
225
+ * @returns {object | null}
226
+ */
227
+ export function resolveWorkspaceOverride(workspaceId, scopes) {
228
+ if (!isWorkspaceKey(workspaceId)) return null
229
+ const entry = scopes && scopes[workspaceId]
230
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
231
+ return isEmptyOverride(entry) ? null : entry
232
+ }
233
+
234
+ /**
235
+ * 把工作区覆盖合并进当前配置(**部分覆盖**语义)。
236
+ *
237
+ * 合并顺序无关(只有一层),但必须保证:
238
+ * - 未声明的字段保持原值;
239
+ * - `enabled` 等包封字段**绝不**被覆盖(即便它在 override 里,也在这里再挡一次 ——
240
+ * sanitize 已经挡过,但这是最后一道,成本为零);
241
+ * - 未知字段不落进结果(防止把脏数据带进编译)。
242
+ *
243
+ * @param {object} base 当前生效的配置(全局,或已合并过更高优先级的层)
244
+ * @param {object | null} override 工作区覆盖
245
+ * @returns {object} 合并后的配置(新对象,绝不就地修改 base)
246
+ */
247
+ export function mergeWorkspaceOverride(base, override) {
248
+ if (!override || typeof override !== 'object') return base
249
+
250
+ const patch = {}
251
+ for (const [key, value] of Object.entries(override)) {
252
+ if (ENVELOPE_ONLY_FIELDS.includes(key)) continue
253
+ if (!WORKSPACE_OVERRIDE_FIELDS.includes(key)) continue
254
+ // 上面两行已经把引用字段(__persona)挡在外面 —— 它不是可覆盖字段。
255
+ // 这里显式写出来,是为了让"引用字段不会进配置"成为可读的事实而不是巧合。
256
+ if (key === WORKSPACE_PERSONA_REF) continue
257
+ patch[key] = value
258
+ }
259
+ if (Object.keys(patch).length === 0) return base
260
+ return { ...base, ...patch }
261
+ }
262
+
263
+ /**
264
+ * 从预设条目里挑出「工作区可覆盖」的字段。
265
+ *
266
+ * 与 `index.mjs` 的 `pickPersonaValues` **同一条语义**(`if (key in persona)` 即
267
+ * 「只应用条目实际声明过的键」),但只取 `WORKSPACE_OVERRIDE_FIELDS` 这一子集:
268
+ * 那个函数还包含 `enabled` 之外的人设字段,而工作区覆盖的白名单更窄。
269
+ *
270
+ * 刻意不直接复用同名函数:它定义在 `index.mjs`(依赖宿主 ctx,不可独立加载),
271
+ * 而本模块必须能被 `scripts/verify-*.mjs` 单独 import 测试。语义一致性由断言守住,
272
+ * 而不是靠共享代码 —— 两者的白名单本来就不同,共享反而会诱使它们漂移到一起。
273
+ *
274
+ * @param {object} persona
275
+ * @returns {Record<string, unknown>}
276
+ */
277
+ function pickOverridableValues(persona) {
278
+ const values = {}
279
+ for (const key of PERSONA_FIELDS) {
280
+ if (!WORKSPACE_OVERRIDE_FIELDS.includes(key)) continue
281
+ if (key in persona) values[key] = persona[key]
282
+ }
283
+ return values
284
+ }
285
+
286
+ /**
287
+ * 把「人设预设」换算成一份覆盖项。
288
+ *
289
+ * 与 `/api/soul/personas/use`、`configForCwd` 走**同一条**折算语义(部分覆盖 +
290
+ * 白名单过滤)。三处若各自实现一遍,就会出现「点使用得到的效果」与「绑定得到的效果」
291
+ * 对不上 —— 而那正是用户用来验证绑定是否生效的唯一手段。
292
+ *
293
+ * @param {string} personaName
294
+ * @param {Record<string, object>} personas 预设库
295
+ * @returns {object | null} 预设不存在时返回 null
296
+ */
297
+ export function overrideFromPersona(personaName, personas) {
298
+ const entry = resolvePersona(personaName, personas)
299
+ if (!entry) return null
300
+ const values = pickOverridableValues(entry)
301
+ const override = {}
302
+ for (const [key, value] of Object.entries(values)) {
303
+ if (ENVELOPE_ONLY_FIELDS.includes(key)) continue
304
+ if (!WORKSPACE_OVERRIDE_FIELDS.includes(key)) continue
305
+ override[key] = value
306
+ }
307
+ return isEmptyOverride(override) ? null : override
308
+ }
309
+
310
+ /**
311
+ * 校验一次「给工作区绑定人设」的请求。
312
+ *
313
+ * @param {{ workspaceId: unknown, persona: unknown }} input
314
+ * @param {Record<string, object>} personas 预设库
315
+ * @returns {{ ok: true, workspaceId: string, persona: string, override: object }
316
+ * | { ok: false, error: string }}
317
+ */
318
+ export function validateWorkspaceBinding(input, personas) {
319
+ const workspaceId = input && input.workspaceId
320
+ if (!isWorkspaceKey(workspaceId)) {
321
+ return { ok: false, error: '工作区标识无效:必须是非空字符串,且不能是路径' }
322
+ }
323
+
324
+ const persona = input && input.persona
325
+ if (typeof persona !== 'string') {
326
+ return { ok: false, error: '人设名必须是字符串' }
327
+ }
328
+ const trimmed = persona.trim()
329
+ if (trimmed === '') {
330
+ return { ok: false, error: '人设名不能为空' }
331
+ }
332
+ // 长度与字符集复用命名校验:允许写入一个「命名不合法」的人设名,等于制造一条
333
+ // 永远无法通过任何界面删除的僵尸覆盖。
334
+ if (trimmed.length > PERSONA_NAME_MAX || sanitizePersonaName(trimmed) === null) {
335
+ return { ok: false, error: `人设名不合法:${trimmed}` }
336
+ }
337
+
338
+ const override = overrideFromPersona(trimmed, personas)
339
+ if (override === null) {
340
+ return { ok: false, error: `人设「${trimmed}」不存在(或该人设未声明任何可覆盖字段)` }
341
+ }
342
+
343
+ // 记下「当初绑的是谁」:预设日后被改名/删除时,界面才能如实报告失效引用,
344
+ // 而不是只剩一堆说不出出处的字段值。
345
+ override[WORKSPACE_PERSONA_REF] = trimmed
346
+
347
+ return { ok: true, workspaceId, persona: trimmed, override }
348
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "description": "为 DeepSeek Harness 添加个性化设置。",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -18,15 +18,20 @@
18
18
  "type": "module",
19
19
  "main": "index.mjs",
20
20
  "scripts": {
21
- "verify": "node scripts/verify-config.mjs && node scripts/verify-store.mjs && node scripts/verify-client.mjs && node scripts/verify-e2e-prompt.mjs && node scripts/verify-live-prompt.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
22
- "verify:strict": "node scripts/verify-config.mjs --strict && node scripts/verify-store.mjs --strict && node scripts/verify-client.mjs --strict && node scripts/verify-e2e-prompt.mjs --strict && node scripts/verify-live-prompt.mjs --strict && node scripts/verify-compat.mjs --strict && node scripts/verify-trail.mjs --strict && node scripts/verify-nav-icon.mjs --strict",
21
+ "verify": "node scripts/verify-config.mjs && node scripts/verify-store.mjs && node scripts/verify-history.mjs && node scripts/verify-bindings.mjs && node scripts/verify-scope.mjs && node scripts/verify-session-scope.mjs && node scripts/verify-client.mjs && node scripts/verify-e2e-prompt.mjs && node scripts/verify-live-prompt.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
22
+ "verify:strict": "node scripts/verify-config.mjs --strict && node scripts/verify-store.mjs --strict && node scripts/verify-history.mjs --strict && node scripts/verify-bindings.mjs --strict && node scripts/verify-scope.mjs --strict && node scripts/verify-session-scope.mjs --strict && node scripts/verify-client.mjs --strict && node scripts/verify-e2e-prompt.mjs --strict && node scripts/verify-live-prompt.mjs --strict && node scripts/verify-compat.mjs --strict && node scripts/verify-trail.mjs --strict && node scripts/verify-nav-icon.mjs --strict",
23
+ "verify:config": "node scripts/verify-config.mjs",
23
24
  "verify:store": "node scripts/verify-store.mjs",
25
+ "verify:history": "node scripts/verify-history.mjs",
26
+ "verify:bindings": "node scripts/verify-bindings.mjs",
24
27
  "verify:client": "node scripts/verify-client.mjs",
25
28
  "verify:e2e": "node scripts/verify-e2e-prompt.mjs",
26
29
  "verify:host": "node scripts/verify-live-prompt.mjs",
27
30
  "verify:compat": "node scripts/verify-compat.mjs",
28
31
  "verify:trail": "node scripts/verify-trail.mjs",
29
- "verify:nav-icon": "node scripts/verify-nav-icon.mjs"
32
+ "verify:nav-icon": "node scripts/verify-nav-icon.mjs",
33
+ "verify:scope": "node scripts/verify-scope.mjs",
34
+ "verify:session-scope": "node scripts/verify-session-scope.mjs"
30
35
  },
31
36
  "license": "MIT",
32
37
  "repository": {