dsh-soul 0.7.0 → 0.7.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.
Files changed (30) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -19
  3. package/README_EN.md +36 -19
  4. package/assets/personas/custom-placeholder.webp +0 -0
  5. package/assets/personas/placeholder.svg +16 -0
  6. package/assets/personas//344/270/245/346/240/274/344/273/243/347/240/201/345/256/241/351/230/205/350/200/205.webp +0 -0
  7. package/assets/personas//345/244/264/350/204/221/351/243/216/346/232/264/344/274/231/344/274/264.webp +0 -0
  8. package/assets/personas//346/225/231/345/255/246/345/236/213/350/256/262/350/247/243/350/200/205.webp +0 -0
  9. package/assets/personas//346/236/201/347/256/200/344/270/273/344/271/211/350/200/205.webp +0 -0
  10. package/assets/personas//350/207/252/351/251/261/345/236/213/345/215/217/344/275/234/350/200/205.webp +0 -0
  11. package/assets/personas//350/213/217/346/240/274/346/213/211/345/272/225/345/274/217/346/217/220/351/227/256/350/200/205.webp +0 -0
  12. package/assets/personas//350/265/204/346/267/261/346/236/266/346/236/204/345/270/210.webp +0 -0
  13. package/client/index.mjs +1169 -214
  14. package/index.mjs +732 -85
  15. package/lib/config.mjs +42 -6
  16. package/lib/injection.mjs +2 -2
  17. package/lib/personas.mjs +62 -25
  18. package/lib/store.mjs +125 -0
  19. package/locale/en.json +6 -0
  20. package/locale/zh.json +6 -0
  21. package/package.json +13 -2
  22. package/scripts/lib/skip-report.mjs +73 -0
  23. package/scripts/verify-client.mjs +1319 -0
  24. package/scripts/verify-compat.mjs +58 -9
  25. package/scripts/verify-config.mjs +2116 -44
  26. package/scripts/verify-e2e-prompt.mjs +627 -0
  27. package/scripts/verify-live-prompt.mjs +445 -0
  28. package/scripts/verify-nav-icon.mjs +35 -7
  29. package/scripts/verify-store.mjs +310 -0
  30. package/scripts/verify-trail.mjs +21 -8
package/lib/config.mjs CHANGED
@@ -73,8 +73,30 @@ const TEXT_FIELDS = ['nickname', 'occupation', 'bio', 'customInstructions']
73
73
 
74
74
  // ==================== 人设预设 ====================
75
75
 
76
- // 人设预设覆盖的配置字段(enabled 为全局开关、不进入预设)
77
- export const PERSONA_FIELDS = ['nickname', 'occupation', 'bio', 'style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
76
+ // 「关于你」:使用者**本人**的身份信息(昵称 / 职业 / 介绍)。
77
+ //
78
+ // 它与人设是两码事:人设描述 **Agent 怎么说话**,关于你描述 **你在跟谁说话**。
79
+ // 所以任何预设(内置或自建)都不声明、也不应用这三个字段 —— 切换预设只换回复风格,
80
+ // 不该动到你的资料。这条不靠「约定」而靠结构保证:三个字段根本不在 PERSONA_FIELDS
81
+ // 里,应用的取键、匹配的判据、保存的快照、磁盘的归一化全部按白名单走。
82
+ //
83
+ // v0.7.1 起自建预设与内置预设语义一致。此前自建预设是「保存那一刻的完整配置快照」,
84
+ // 含这三个字段,应用时会把它们一并写回(旧数据由 normalizePersonas 迁移剥离)。
85
+ export const PROFILE_FIELDS = ['nickname', 'occupation', 'bio']
86
+
87
+ // 人设预设覆盖的配置字段:**只含 Agent 的人格与回复风格**,不含「关于你」(见上)。
88
+ // - language 在范围内:它决定 Agent 用什么语言作答,属于人格的一部分(自建预设
89
+ // 会保存与还原它;内置预设作为通用人格刻意不声明,见 lib/personas.mjs 约定 3)。
90
+ // - enabled 是全局开关,不进预设 —— 预设不该替你关掉个性化。
91
+ export const PERSONA_FIELDS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language', 'customInstructions']
92
+
93
+ // 参与 compilePrompt 的字段 = 总开关 + 关于你 + 人设。
94
+ // 刻意与 PERSONA_FIELDS 分开放:**预设能覆盖的字段**与**编译实际读取的字段**不是一回事,
95
+ // 混成一个常量会让「预设范围」的增删意外改变提示词预览的脏检查范围。
96
+ export const PROMPT_INPUT_FIELDS = ['enabled', ...PROFILE_FIELDS, ...PERSONA_FIELDS]
97
+
98
+ // 预设条目里的元数据键:不属于人设维度,不参与比较与匹配(builtin 仅存在于合并视图)
99
+ export const PERSONA_META_KEYS = new Set(['updatedAt', 'builtin'])
78
100
 
79
101
  // 人设预设名称上限
80
102
  export const PERSONA_NAME_MAX = 30
@@ -91,7 +113,13 @@ export function sanitizePersonaName(raw) {
91
113
  return name
92
114
  }
93
115
 
94
- // 归一化磁盘上的预设库:只保留普通对象条目,剔除保留键与非对象条目
116
+ // 归一化磁盘上的预设库:只保留普通对象条目,剔除保留键与非对象条目。
117
+ //
118
+ // 同时按白名单过滤条目字段(v0.7.1 迁移):只留 PERSONA_FIELDS 内的人设字段与
119
+ // PERSONA_META_KEYS 里的元数据。历史版本的自建预设是完整配置快照,带着
120
+ // nickname / occupation / bio —— 它们已不再是预设的一部分,留着会让人误以为
121
+ // 「应用这个预设能恢复我的昵称」。丢弃无损:这些值同时存在于活动配置里
122
+ // (预设只是保存那一刻的拷贝),迁移后的结果会在下一次写入时落盘。
95
123
  export function normalizePersonas(raw) {
96
124
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
97
125
  const out = {}
@@ -99,7 +127,11 @@ export function normalizePersonas(raw) {
99
127
  if (FORBIDDEN_PERSONA_NAMES.has(key)) continue
100
128
  const entry = raw[key]
101
129
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
102
- out[key] = { ...entry }
130
+ const clean = {}
131
+ for (const field of Object.keys(entry)) {
132
+ if (PERSONA_FIELDS.includes(field) || PERSONA_META_KEYS.has(field)) clean[field] = entry[field]
133
+ }
134
+ out[key] = clean
103
135
  }
104
136
  return out
105
137
  }
@@ -233,8 +265,12 @@ export function sanitizeConfig(raw) {
233
265
  for (const [key, values, label] of ENUM_CHECKS) {
234
266
  if (!(key in raw)) continue
235
267
  const value = raw[key]
236
- if (typeof value === 'string' && values.includes(value)) {
237
- patch[key] = value
268
+ // 枚举值同样 trim:文本字段(TEXT_FIELDS)本来就 trim,枚举不 trim 会让
269
+ // `/soul set style= humorous`(= 后带空格,解析出 ' humorous')被拒,
270
+ // 而 `bio= 张三` 却能成功 —— 同一命令里两套规则。
271
+ const text = typeof value === 'string' ? value.trim() : ''
272
+ if (values.includes(text)) {
273
+ patch[key] = text
238
274
  } else {
239
275
  errors[key] = `${label}必须是 ${values.join(' / ')} 之一`
240
276
  }
package/lib/injection.mjs CHANGED
@@ -21,10 +21,10 @@
21
21
  // 取 `plugin:dsh-soul` 而非裸名,是为了与 DSH 自带 v3→v4 迁移对第三方插件生成的
22
22
  // `plugin:${plugin}`(producerKind 的回落分支)保持一致:升级前后的历史事件与
23
23
  // 新增事件归属同一个生产者,而不是在同一个会话里出现两个身份。
24
- export const INJECTION_PRODUCER_KIND = 'plugin:dsh-soul'
24
+ const INJECTION_PRODUCER_KIND = 'plugin:dsh-soul'
25
25
 
26
26
  // 快照区块名,与 systemPrompt section 的 name 对应
27
- export const INJECTION_SECTION_NAME = 'soul:persona'
27
+ const INJECTION_SECTION_NAME = 'soul:persona'
28
28
 
29
29
  /**
30
30
  * 构造注入消息的 source:自持 kind + snapshot 形态 + 命名区块。
package/lib/personas.mjs CHANGED
@@ -4,17 +4,37 @@
4
4
  // - 内置人设清单(BUILTIN_PERSONAS)
5
5
  // - 用户预设库与内置预设的合并(mergePersonas / resolvePersona)
6
6
  //
7
- // 三条设计约定(verify-config.mjs 逐条固化,改动时勿破坏):
7
+ // 四条设计约定(verify-config.mjs 逐条固化,改动时勿破坏):
8
8
  //
9
- // 1) **只声明要覆盖的字段**。预设是「部分覆盖」语义:应用时只写入预设实际
10
- // 声明的键,未声明的保持用户当前值。所以内置预设一律省略
11
- // `nickname` / `occupation` / `bio`——那是「关于你」的用户自有信息,
12
- // 预设去写它们等于把用户的昵称职业清空。
9
+ // 1) **预设只描述 Agent 的人格,不含「关于你」**。「关于你」(昵称 / 职业 / 介绍)
10
+ // 是使用者本人的身份信息,「人设」是 Agent 的回复风格 —— 两码事。切换预设应该
11
+ // 只换说话方式,不该动你的资料。这条由 lib/config.mjs 的 PROFILE_FIELDS /
12
+ // PERSONA_FIELDS 结构性保证:三个字段不在 PERSONA_FIELDS 里,所以不止内置预设,
13
+ // 连**自建**预设也不会声明或应用它们(保存走 personaSnapshotOf、应用走
14
+ // pickPersonaValues,两者都只遍历 PERSONA_FIELDS)。v0.7.1 之前自建预设是
15
+ // 「完整配置快照」、会写回这三个字段,旧数据由 normalizePersonas 迁移剥离。
13
16
  //
14
- // 2) **不声明 `language`**。输出语言属于用户偏好而非人设的一部分;内置预设若
15
- // 写死 zh,英文用户应用一次就会被强行切回中文。省略即「不动它」。
17
+ // 2) **声明全部 6 个人格维度**。每个内置预设都显式列出 style / headingLists /
18
+ // emoji / tables / replyLength / customInstructions,即使某项就是默认值。
16
19
  //
17
- // 3) **内置预设不进磁盘**。它们只存在于本文件,磁盘上的 `config.personas`
20
+ // 理由:这份声明列表**同时驱动应用与匹配**,而两者的需求相反 ——
21
+ // 应用时「只写声明字段」⇒ 声明越少越不越权;
22
+ // 匹配时「只比声明字段」⇒ 声明越少越宽松。
23
+ // 早期为「不越权」只声明要改的维度,代价是匹配退化成宽松比较:改一个预设
24
+ // 未声明的特质后 ★ 仍挂在它身上,而且**同一动作结果随预设而变**(改 emoji
25
+ // 在「极简主义者」上丢 ★,在「苏格拉底式提问者」上不丢)—— 用户无法形成
26
+ // 稳定预期(实测确认)。统一为全量声明后语义单一可预期:
27
+ // **改任何特质 ⇒ 不再属于该预设**。
28
+ //
29
+ // 代价(刻意接受):应用预设会把 6 个人格维度整体复位到预设值,而不是
30
+ // 「只改一部分、其余保留我手动的」—— 这与「用预设就是不想逐项自己配」
31
+ // 的心智模型一致。
32
+ //
33
+ // 3) **内置预设一律不声明 `language`**。输出语言在预设范围内(自建预设会保存
34
+ // 与还原它),但内置预设是给任何语言用户共用的通用人格:写死 zh 会让英文用户
35
+ // 应用一次就被强行切回中文。省略即「不动它」——这是全量声明唯一的例外。
36
+ //
37
+ // 4) **内置预设不进磁盘**。它们只存在于本文件,磁盘上的 `config.personas`
18
38
  // 仅存用户自己保存的预设。好处有三:升级时内置内容自动更新(不会被用户
19
39
  // 的旧副本覆盖)、用户无法删除或篡改内置项、`$DSH_HOME/soul-config.json`
20
40
  // 保持干净。写入路径(保存 / 删除)对内置名一律拒绝,因此不存在「删了又
@@ -24,7 +44,7 @@
24
44
  // 合并时会被内置内容遮蔽,且该名字在 UI 上不可删除——不变量是「内置名永远指向
25
45
  // 内置内容」。保存路径同样拒绝内置名,用户想微调就换个名字存。
26
46
 
27
- import { PERSONA_FIELDS } from './config.mjs'
47
+ import { PERSONA_FIELDS, PERSONA_META_KEYS } from './config.mjs'
28
48
 
29
49
  // ==================== 内置人设 ====================
30
50
  //
@@ -38,6 +58,9 @@ import { PERSONA_FIELDS } from './config.mjs'
38
58
 
39
59
  export const BUILTIN_PERSONAS = {
40
60
  '苏格拉底式提问者': {
61
+ emoji: 'default',
62
+ tables: 'default',
63
+ replyLength: 'normal',
41
64
  style: 'professional',
42
65
  headingLists: 'less',
43
66
  customInstructions:
@@ -58,6 +81,7 @@ export const BUILTIN_PERSONAS = {
58
81
  '若信息不足以回答,只问一个最关键的问题,不要罗列一串待确认项。'
59
82
  },
60
83
  '资深架构师': {
84
+ emoji: 'default',
61
85
  style: 'professional',
62
86
  headingLists: 'more',
63
87
  tables: 'more',
@@ -69,6 +93,8 @@ export const BUILTIN_PERSONAS = {
69
93
  '涉及取舍时给出明确推荐并说明理由,不要以「都可以,看需求」收尾。'
70
94
  },
71
95
  '教学型讲解者': {
96
+ emoji: 'default',
97
+ tables: 'default',
72
98
  style: 'casual',
73
99
  headingLists: 'more',
74
100
  replyLength: 'detailed',
@@ -91,6 +117,9 @@ export const BUILTIN_PERSONAS = {
91
117
  '没有发现问题时,明确说明检查过哪些方面,不要为凑数编造无关痛痒的意见。'
92
118
  },
93
119
  '头脑风暴伙伴': {
120
+ emoji: 'default',
121
+ tables: 'default',
122
+ replyLength: 'normal',
94
123
  style: 'casual',
95
124
  headingLists: 'more',
96
125
  customInstructions:
@@ -98,15 +127,29 @@ export const BUILTIN_PERSONAS = {
98
127
  '每个方案用一句话说清核心思路与主要代价。' +
99
128
  '发散阶段不要否定任何想法;给出方案后再标出你个人最看好的一个及理由。' +
100
129
  '避免只给显而易见的选项,至少包含一个不常规但有可行性的思路。'
130
+ },
131
+ '自驱型协作者': {
132
+ tables: 'default',
133
+ replyLength: 'normal',
134
+ style: 'professional',
135
+ headingLists: 'more',
136
+ emoji: 'more',
137
+ customInstructions:
138
+ '先自己动手再开口:把能读的读了、能查的查了、能验证的验证掉——读文件、看上下文、检索资料都算;' +
139
+ '确实卡住才提问,并把已排查的范围与还缺什么一并说明,目标是带着方案回来而不是带着问题。' +
140
+ '给出明确判断:比较方案时指明你更推荐哪一条及理由,不以「都可以,看需求」收尾;有不同意见就直接讲,摆出分歧点与依据。' +
141
+ '以可核实的依据为准:涉及事实、接口、参数、版本时先查证再断言,查不到就明说「不确定」,不用听起来合理的说法填空。' +
142
+ '实质优先于形式:直接从结论或第一步行动开始,不复述用户已经说清的需求,也不用「好问题」「我很乐意帮忙」这类开场垫话——' +
143
+ '礼貌体现在措辞上,不体现在铺垫上。' +
144
+ '区分两类动作:读文件、检索、整理这类内部动作大胆做;发消息、提交、发布、删除这类对外动作先确认。不发送未经整理的回复。'
101
145
  }
102
146
  }
103
147
 
104
148
  // ==================== 合并与查询 ====================
105
149
 
106
- // 预设条目的元数据键:不属于人设字段,参与比较时须剔除
107
- const PERSONA_META_KEYS = new Set(['updatedAt', 'builtin'])
108
-
109
- // 预设实际声明的人设字段名(只保留 PERSONA_FIELDS 白名单内的键)
150
+ // 预设实际声明的人设字段名(只保留 PERSONA_FIELDS 白名单内的键)。
151
+ // 白名单同时过滤掉元数据与「关于你」——后者连历史数据里残留的也不会被算作声明,
152
+ // 于是匹配对昵称 / 职业 / 介绍天然免疫(见文件头约定 1)。
110
153
  export function declaredPersonaKeys(entry) {
111
154
  return Object.keys(entry || {}).filter(
112
155
  (key) => !PERSONA_META_KEYS.has(key) && PERSONA_FIELDS.includes(key)
@@ -117,8 +160,8 @@ export function declaredPersonaKeys(entry) {
117
160
  //
118
161
  // 预设是「部分覆盖」语义——应用时只写它声明过的字段,所以判定也必须只比较
119
162
  // 声明过的字段。若按 PERSONA_FIELDS 全量比较,内置预设会永远匹配不上:它们
120
- // 刻意省略 nickname / occupation / bio(用户自有信息)与 language(输出语言属于
121
- // 用户偏好),省略的含义是「不动它」,而不是「要求它是空」。
163
+ // 刻意只声明自己真正要改变的人格维度,没声明的含义是「不动它」,而不是「要求
164
+ // 它是默认值」。
122
165
  //
123
166
  // 一个字段都没声明的条目视为不匹配——否则它会匹配任何配置。
124
167
  export function personaMatches(entry, config) {
@@ -127,19 +170,20 @@ export function personaMatches(entry, config) {
127
170
  return keys.every((key) => entry[key] === config[key])
128
171
  }
129
172
 
130
- // 内置人设名(按定义顺序返回;UI 列表用它把内置项排在前面的展示区)
173
+ // 内置人设名(按定义顺序返回;UI 列表用它把内置项排在前面的展示区)。
174
+ // 「默认」排在最前 —— 它是出厂状态,列表里应当一眼可见。
131
175
  export function builtinPersonaNames() {
132
176
  return Object.keys(BUILTIN_PERSONAS)
133
177
  }
134
178
 
135
- // 是否为内置人设名(保存与删除路径的准入判据)
179
+ // 是否为内置人设名(保存与删除路径的准入判据)。默认与内置同样受保护。
136
180
  export function isBuiltinPersona(name) {
137
181
  return typeof name === 'string' && Object.prototype.hasOwnProperty.call(BUILTIN_PERSONAS, name)
138
182
  }
139
183
 
140
184
  // 合并用户预设库与内置预设,返回给前端的展示视图。
141
185
  // 内置条目额外带 `builtin: true` —— 前端据此渲染「内置」标记并隐藏删除按钮;
142
- // 用户预设不带该字段。内置优先,同名用户条目被遮蔽(见文件头约定 3)。
186
+ // 用户预设不带该字段。内置优先(默认在最前),同名用户条目被遮蔽(见文件头约定 3)。
143
187
  export function mergePersonas(userPersonas) {
144
188
  const out = {}
145
189
  for (const name of builtinPersonaNames()) {
@@ -163,10 +207,3 @@ export function resolvePersona(name, userPersonas) {
163
207
  }
164
208
  return null
165
209
  }
166
-
167
- // 预设声明的字段数(UI 摘要与测试用;内置预设字段少于用户预设,因为前者
168
- // 刻意省略用户自有信息)。
169
- export function declaredFieldCount(persona) {
170
- if (!persona || typeof persona !== 'object') return 0
171
- return Object.keys(persona).filter((key) => key !== 'updatedAt' && key !== 'builtin').length
172
- }
package/lib/store.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // lib/store.mjs — dsh-soul 配置持久化(文件 IO,无 cordis 依赖)
2
+ //
3
+ // 职责:
4
+ // - 读取配置文件,并把「文件不存在」与「文件存在但读不出来 / 不是合法 JSON」区分开
5
+ // - 配置文件的原子写入(临时文件 + rename)
6
+ // - 损坏文件的备份
7
+ //
8
+ // 本模块只负责如实报告结果,不替调用方决定如何回退;可被 scripts/verify-store.mjs
9
+ // 独立加载并针对真实文件系统测试。
10
+
11
+ import { readFile, writeFile, mkdir, rename, copyFile } from 'node:fs/promises'
12
+ import { dirname } from 'node:path'
13
+
14
+ // 损坏配置的备份路径(与配置文件同目录)
15
+ export function corruptBackupPath(filePath) {
16
+ return `${filePath}.corrupt`
17
+ }
18
+
19
+ // 把损坏的配置文件另存为 <file>.corrupt,供用户找回内容。
20
+ // 备份失败返回 null 而不抛错 —— 备份是尽力而为,不能因为备份失败就让读取路径也失败。
21
+ async function backupCorruptFile(filePath) {
22
+ const target = corruptBackupPath(filePath)
23
+ try {
24
+ await copyFile(filePath, target)
25
+ return target
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ // 把不可用的配置文件**移开**(而非复制),备份到 <file>.corrupt,返回备份路径。
32
+ //
33
+ // 与 backupCorruptFile 的差别只有一个,但这个差别很关键:移开之后原路径不再存在,
34
+ // 读取随即回到「首次运行」的正常分支。这是「重置」这类**刻意丢弃**动作需要的语义——
35
+ // 只复制而保留原文件的话,下一次读取仍会判定为损坏,于是重置依然被拒绝,
36
+ // 用户除手删文件外没有任何出路(这正是本函数存在的原因)。
37
+ //
38
+ // 目标已存在时会被 rename 覆盖(Node 在 Windows 用 MOVEFILE_REPLACE_EXISTING)。
39
+ // 文件本就不存在 / 移不动时返回 null —— 调用方据此判断是否真的腾出了位置。
40
+ export async function moveAsideConfigFile(filePath) {
41
+ const target = corruptBackupPath(filePath)
42
+ try {
43
+ await rename(filePath, target)
44
+ return target
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ // 读取并解析配置文件,返回 { value, exists, failure }:
51
+ //
52
+ // | 情况 | value | exists | failure |
53
+ // | ------------------------ | ----- | ------ | -------------------------------- |
54
+ // | 文件不存在(首次运行) | null | false | null(不算错误) |
55
+ // | 读到且是合法 JSON | 解析值| true | null |
56
+ // | 存在但读不出来(权限等) | null | true | { code:'unreadable' } |
57
+ // | 存在但不是合法 JSON | null | true | { code:'invalid-json', backupPath} |
58
+ //
59
+ // failure 是「磁盘上的配置不可用」的如实报告 —— 调用方据此决定是否回退默认值,
60
+ // 以及是否拒绝后续写入。这里刻意不吞掉失败:静默回退会让调用方以为读到了一份
61
+ // 空配置,进而在下一次写入时把用户原有的配置整体覆盖掉。
62
+ export async function readConfigFile(filePath) {
63
+ let text
64
+ try {
65
+ text = await readFile(filePath, 'utf8')
66
+ } catch (err) {
67
+ if (err && err.code === 'ENOENT') {
68
+ return { value: null, exists: false, failure: null }
69
+ }
70
+ return {
71
+ value: null,
72
+ exists: true,
73
+ failure: {
74
+ code: 'unreadable',
75
+ message: `配置文件读取失败(${(err && err.code) || 'unknown'}):${(err && err.message) || err}`,
76
+ backupPath: null
77
+ }
78
+ }
79
+ }
80
+
81
+ try {
82
+ return { value: JSON.parse(text), exists: true, failure: null }
83
+ } catch (err) {
84
+ return {
85
+ value: null,
86
+ exists: true,
87
+ failure: {
88
+ code: 'invalid-json',
89
+ message: `配置文件不是合法的 JSON(${(err && err.message) || err})`,
90
+ backupPath: await backupCorruptFile(filePath)
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ // 把读取失败翻译成用户可读的说明(含损坏文件的备份位置)
97
+ export function describeConfigFailure(failure) {
98
+ if (!failure) return null
99
+ const backup = failure.backupPath ? `(原文件已备份到 ${failure.backupPath})` : ''
100
+ return `${failure.message}${backup}`
101
+ }
102
+
103
+ // 在这份失败之上能否写入:返回 null 表示可以写,返回字符串表示应拒绝并原样展示该原因。
104
+ //
105
+ // 判定下沉在这里(而不是散在调用方),是为了让「损坏的配置绝不会被静默覆盖」这条
106
+ // 规则只有一处实现 —— 写路径有 HTTP 保存 / 斜杠命令 / 工具 / 服务四条。
107
+ export function configWriteRefusal(failure, filePath) {
108
+ if (!failure) return null
109
+ return `${describeConfigFailure(failure)}。为避免覆盖,本次保存已拒绝;请修复或删除 ${filePath} 后重试。`
110
+ }
111
+
112
+ // 原子写入配置:先写同目录的临时文件,再 rename 覆盖。
113
+ //
114
+ // 同目录 rename 是原子替换(Windows 的 MoveFileEx 亦带 REPLACE_EXISTING),
115
+ // 因此文件要么是旧的完整内容、要么是新的完整内容。直接 writeFile 在写到一半
116
+ // 进程被杀时会留下**截断的 JSON**,整份配置随即不可解析。
117
+ //
118
+ // 临时文件名固定(不带随机后缀):写入统一经由 index.mjs 的写队列串行执行,
119
+ // 不存在并发同名临时文件;固定名还能让中断残留的 .tmp 在下次写入时被覆盖。
120
+ export async function writeConfigFile(filePath, config) {
121
+ await mkdir(dirname(filePath), { recursive: true })
122
+ const tmpPath = `${filePath}.tmp`
123
+ await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf8')
124
+ await rename(tmpPath, filePath)
125
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Soul",
4
+ "description": "Personalization for DSH: nickname, reply style and tone, traits, persona presets, and a composer light trail."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Soul 人设",
4
+ "description": "为 DSH 添加个性化设置:昵称、回复风格和语调、特质、人设预设,以及输入框光轨。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-soul",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "为 DeepSeek Harness 添加个性化设置。",
5
5
  "icon": "./assets/icon.svg",
6
6
  "keywords": [
@@ -18,7 +18,12 @@
18
18
  "type": "module",
19
19
  "main": "index.mjs",
20
20
  "scripts": {
21
- "verify": "node scripts/verify-config.mjs && node scripts/verify-compat.mjs && node scripts/verify-trail.mjs && node scripts/verify-nav-icon.mjs",
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",
23
+ "verify:store": "node scripts/verify-store.mjs",
24
+ "verify:client": "node scripts/verify-client.mjs",
25
+ "verify:e2e": "node scripts/verify-e2e-prompt.mjs",
26
+ "verify:host": "node scripts/verify-live-prompt.mjs",
22
27
  "verify:compat": "node scripts/verify-compat.mjs",
23
28
  "verify:trail": "node scripts/verify-trail.mjs",
24
29
  "verify:nav-icon": "node scripts/verify-nav-icon.mjs"
@@ -28,6 +33,10 @@
28
33
  "type": "git",
29
34
  "url": "git+https://github.com/Aliuyanfeng/dsh-soul.git"
30
35
  },
36
+ "homepage": "https://github.com/Aliuyanfeng/dsh-soul#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/Aliuyanfeng/dsh-soul/issues"
39
+ },
31
40
  "peerDependencies": {
32
41
  "@deepseek-ai/cordis": "^4.0.1 || ^4.0.5-alpha.1",
33
42
  "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.3.0-0",
@@ -51,6 +60,7 @@
51
60
  },
52
61
  "./client": "./client/index.mjs",
53
62
  "./cordis.patch.yml": "./cordis.patch.yml",
63
+ "./locale/*.json": "./locale/*.json",
54
64
  "./package.json": "./package.json"
55
65
  },
56
66
  "files": [
@@ -58,6 +68,7 @@
58
68
  "lib",
59
69
  "client",
60
70
  "assets",
71
+ "locale",
61
72
  "cordis.patch.yml",
62
73
  "scripts",
63
74
  "README.md",
@@ -0,0 +1,73 @@
1
+ // scripts/lib/skip-report.mjs — 「跳过」的统一语义
2
+ //
3
+ // 为什么需要它:verify-trail / verify-nav-icon 在没有可用浏览器时、verify-e2e /
4
+ // verify-live-prompt / verify-compat 在定位不到 DSH 时都会**跳过**并返回 0。它们串在
5
+ // `npm run verify` 的 `&&` 链里,于是最终输出看起来一片绿 —— 实际有几十项断言根本没跑。
6
+ // 这是最误导人的一种报告:它把「没有验证」显示成了「验证通过」。
7
+ //
8
+ // 约定:
9
+ // 1. 跳过时必须显式打印「有 N 项断言本次未执行」,而不是静默退出 0;
10
+ // 2. 传 `--strict` 时跳过按失败处理(rc=1),供「本机本该有浏览器 / 本该有 DSH」的场景;
11
+ // 3. 断言数量由各脚本自己声明(`EXPECTED_ASSERTIONS`),并在真正跑完时用
12
+ // `assertCount` 校验 —— 脚本加了断言却忘了更新数字时,跑得起来的那一次会失败,
13
+ // 从而保证跳过提示里的数字不会悄悄变成谎话。
14
+ // 4. 只有**整脚本**跳过用 `skipExit`;脚本内部个别断言无条件执行用 `skipNote`,
15
+ // 由调用方把缺口计入声明数、并据返回值判定(strict 下这种缺口也算失败)。
16
+
17
+ /** 是否处于 --strict(跳过即失败)。 */
18
+ export function strictMode() {
19
+ return process.argv.includes('--strict')
20
+ }
21
+
22
+ /**
23
+ * 跳过并以统一格式说明「本次没有验证什么」。
24
+ * @param {string} tool 脚本名(用于报告)
25
+ * @param {number} assertions 本次未执行的断言数
26
+ * @param {string} reason 跳过原因(要能指导用户怎么补跑)
27
+ * @param {string} [hint] 补跑方式
28
+ */
29
+ export function skipExit(tool, assertions, reason, hint) {
30
+ console.log('')
31
+ console.log(` ⚠ 跳过 ${tool}:${reason}`)
32
+ console.log(` → 本次有 ${assertions} 项断言**未执行**。跳过不等于通过。`)
33
+ if (hint) console.log(` → 补跑:${hint}`)
34
+ if (strictMode()) {
35
+ console.log('结果:跳过(--strict 模式下按失败处理)。')
36
+ process.exit(1)
37
+ }
38
+ console.log('结果:跳过(不算通过)。加 --strict 可让跳过直接失败。')
39
+ process.exit(0)
40
+ }
41
+
42
+ /**
43
+ * 校验实际跑出的断言数与脚本声明的数量一致。
44
+ * @returns {boolean} 一致为 true;不一致时已打印说明,调用方应判失败。
45
+ */
46
+ export function assertCount(tool, actual, declared) {
47
+ if (actual === declared) return true
48
+ console.log('')
49
+ console.log(` ✗ ${tool} 的断言数与脚本声明不一致:实际 ${actual} 项,声明 ${declared} 项。`)
50
+ console.log(' 本次运行并不可信;请把脚本里的 EXPECTED_ASSERTIONS 更新为实际值后重跑')
51
+ console.log(' (跳过提示与 --strict 的判定都依赖这个数字)。')
52
+ return false
53
+ }
54
+
55
+ /**
56
+ * 报告脚本**内部**某一项断言因平台 / 环境能力缺失而无法执行。
57
+ *
58
+ * 与 `skipExit` 的分工:`skipExit` 是整个脚本都不跑(直接结束进程,用于「没有浏览器 /
59
+ * 定位不到 DSH」这类整脚本级跳过);本函数用于「其余断言照常跑完,只有其中一项没条件
60
+ * 执行」的情形 —— 调用方把它计入声明数,并据返回值决定是否判失败。
61
+ *
62
+ * @param {string} tool 脚本名(用于报告)
63
+ * @param {number} assertions 本次未执行的断言数
64
+ * @param {string} reason 为什么这一项跑不了
65
+ * @param {string} [hint] 什么条件下能补跑
66
+ * @returns {boolean} 非 strict 时 true(可接受);strict 时 false(调用方应判失败)
67
+ */
68
+ export function skipNote(tool, assertions, reason, hint) {
69
+ console.log(` ⚠ 跳过 ${tool}:${reason}`)
70
+ console.log(` → 本次有 ${assertions} 项断言**未执行**。跳过不等于通过。`)
71
+ if (hint) console.log(` → 补跑:${hint}`)
72
+ return !strictMode()
73
+ }