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.
- package/README.md +39 -2
- package/README_EN.md +39 -2
- package/client/index.mjs +3497 -99
- package/index.mjs +1966 -121
- package/lib/bindings.mjs +430 -0
- package/lib/config.mjs +82 -1
- package/lib/history.mjs +605 -0
- package/lib/personas.mjs +59 -0
- package/lib/session-scope.mjs +432 -0
- package/lib/workspace-scope.mjs +348 -0
- package/package.json +9 -4
- package/scripts/verify-bindings.mjs +552 -0
- package/scripts/verify-client.mjs +2209 -16
- package/scripts/verify-config.mjs +1929 -24
- package/scripts/verify-e2e-prompt.mjs +1186 -24
- package/scripts/verify-history.mjs +782 -0
- package/scripts/verify-scope.mjs +328 -0
- package/scripts/verify-session-scope.mjs +435 -0
package/lib/bindings.mjs
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// lib/bindings.mjs — dsh-soul 人设工作区绑定(路径规范化 + 前缀匹配 + 绑定表校验)
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// - 目录路径的规范化(统一分隔符、去尾分隔符、解析符号链接 / Junction)
|
|
5
|
+
// - 目录与绑定项的前缀匹配(**必须落在路径边界上**,见 pathMatches)
|
|
6
|
+
// - 绑定表的校验与序列化(读侧一律当不可信输入)
|
|
7
|
+
//
|
|
8
|
+
// 五条设计前提,改本模块前请先读:
|
|
9
|
+
//
|
|
10
|
+
// 1. **目录是主键,不是会话。** 宿主 `dsh-subagent` 让子代理原样继承父会话的
|
|
11
|
+
// `session.header.cwd`,却给它一个全新的 sessionId。纯会话键的方案下,主会话里
|
|
12
|
+
// 生效的人设到了子代理会**静默回落**到全局配置,且没有任何地方能提示用户。
|
|
13
|
+
// 参见 docs/DESIGN-persona-scope.md §2。
|
|
14
|
+
// 2. **前缀匹配必须落在路径边界上。** 裸 startsWith 会让 `D:\项目A` 命中
|
|
15
|
+
// `D:\项目AB`。这条规则只有 pathMatches 一处实现,并有独立断言守着 —— 它是
|
|
16
|
+
// 整个功能里最容易写错、错了以后表现得「像随机」的一行。
|
|
17
|
+
// 3. **路径规范化只用于比较,展示用原样。** 绑定表里存的键是规范化后的**展示形态**
|
|
18
|
+
// (分隔符统一、无尾分隔符、**不折大小写**);大小写折算只发生在 compareKey 里。
|
|
19
|
+
// 否则用户在设置页看到的目录名会与他磁盘上的不一致。
|
|
20
|
+
// 4. **符号链接与 Junction 必须解析。** 这不是理论问题:本机
|
|
21
|
+
// `profiles\desktop\node_modules\dsh-soul` 就是 Junction。解析失败(目录已删、
|
|
22
|
+
// 网络盘离线)时**保留原样**,绝不报错 —— 一块离线盘不该让整个设置页崩掉。
|
|
23
|
+
// 5. **本模块只负责如实报告,不替调用方决定要不要回退** —— 与 lib/store.mjs、
|
|
24
|
+
// lib/history.mjs 同一条约定,可被 scripts/verify-config.mjs 独立加载测试。
|
|
25
|
+
//
|
|
26
|
+
// 为什么绑定表是独立文件(`$DSH_HOME/soul-bindings.json`)而不是人设条目的一个字段:
|
|
27
|
+
// 绑定是**本机环境事实**,不是人设的属性。同一份「资深架构师」在台式机与笔记本上绑的
|
|
28
|
+
// 目录必然不同。放进人设条目会立刻带来三个问题:导出人设会带出本机绝对路径、内置人设
|
|
29
|
+
// 不能带路径(它随插件分发)、删人设时绑定该不该一起消失无从定义。
|
|
30
|
+
// 独立表里「绑定指向不存在的人设名 ⇒ 该条失效但保留」是个干净的答案。
|
|
31
|
+
|
|
32
|
+
import { realpath } from 'node:fs/promises'
|
|
33
|
+
import { PERSONA_NAME_MAX, sanitizePersonaName } from './config.mjs'
|
|
34
|
+
import { resolvePersona } from './personas.mjs'
|
|
35
|
+
import { moveAsideConfigFile, readConfigFile, writeConfigFile } from './store.mjs'
|
|
36
|
+
|
|
37
|
+
// ==================== 契约常量 ====================
|
|
38
|
+
|
|
39
|
+
// 绑定表格式版本。读到不认识的版本时**整表忽略**(而不是猜测字段语义):
|
|
40
|
+
// 升级后的键含义无法保证,宁可暂时不生效,也不能按错的方式解析出一堆假绑定。
|
|
41
|
+
export const BINDINGS_VERSION = 1
|
|
42
|
+
|
|
43
|
+
// 绑定目录的路径长度上限。超限**拒绝写入**而不是截断 —— 截断出来的路径仍然
|
|
44
|
+
// 「看起来像个路径」,但它再也不会命中任何会话,用户只会觉得绑定莫名其妙不生效。
|
|
45
|
+
export const MAX_PATH_LENGTH = 512
|
|
46
|
+
|
|
47
|
+
// 绑定条数上限。每轮装配都要扫一遍绑定表,条数必须有界;200 与变更记录的
|
|
48
|
+
// MAX_ENTRIES 同值,是「远超任何真实用法、又小到能一眼看完」的量级。
|
|
49
|
+
export const MAX_BINDINGS = 200
|
|
50
|
+
|
|
51
|
+
// 平台默认值:分隔符与「大小写是否不敏感」。
|
|
52
|
+
//
|
|
53
|
+
// 两者都做成可传入的选项(而不是直接读 process.platform),是为了让断言能在同一台
|
|
54
|
+
// 机器上同时验证两套语义 —— 否则 CI(ubuntu)与开发机(Windows)跑出的结论不同,
|
|
55
|
+
// 「绿」的含义就跟着变了。
|
|
56
|
+
const PLATFORM_SEP = process.platform === 'win32' ? '\\' : '/'
|
|
57
|
+
const PLATFORM_CASE_INSENSITIVE = process.platform === 'win32'
|
|
58
|
+
|
|
59
|
+
// 禁用作绑定目录键的保留字(防原型污染)。JSON.parse 对 `"__proto__"` 会生成
|
|
60
|
+
// **自有属性**,随后 `out[key] = value` 就会改写原型而不是加一条绑定。
|
|
61
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
62
|
+
|
|
63
|
+
function resolveOptions(options) {
|
|
64
|
+
const sep = options && typeof options.sep === 'string' && options.sep.length === 1
|
|
65
|
+
? options.sep
|
|
66
|
+
: PLATFORM_SEP
|
|
67
|
+
const caseInsensitive = options && options.caseInsensitive !== undefined
|
|
68
|
+
? Boolean(options.caseInsensitive)
|
|
69
|
+
: PLATFORM_CASE_INSENSITIVE
|
|
70
|
+
return { sep, caseInsensitive }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ==================== 路径规范化 ====================
|
|
74
|
+
|
|
75
|
+
// 判断一个(已规范的)路径是不是绝对路径。
|
|
76
|
+
//
|
|
77
|
+
// 刻意同时接受三种形态,而不按平台分叉:POSIX 根(`/…`)、Windows 盘符(`D:\…` / `D:/…`)、
|
|
78
|
+
// UNC(`\\server\share`)。在 POSIX 上多认一个 `D:\…` 是无害的(它永远不可能等于真实的
|
|
79
|
+
// 会话 cwd),而在 Windows 上漏认一种却是实打实的功能缺失。
|
|
80
|
+
//
|
|
81
|
+
// `D:项目A`(盘符相对路径)**不算**绝对路径 —— 它的含义取决于该盘当时的当前目录。
|
|
82
|
+
export function isAbsoluteDirPath(raw) {
|
|
83
|
+
if (typeof raw !== 'string') return false
|
|
84
|
+
const text = raw.trim()
|
|
85
|
+
if (text === '') return false
|
|
86
|
+
if (/^[\\/]/.test(text)) return true
|
|
87
|
+
return /^[A-Za-z]:[\\/]/.test(text)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 把任意用户输入(手输、粘贴、系统选择器返回)规范成统一的展示形态:
|
|
91
|
+
// - 分隔符统一成 sep(Windows 下 `\`,POSIX 下 `/`)
|
|
92
|
+
// - 折叠连续分隔符
|
|
93
|
+
// - 去掉尾部分隔符(盘根 `D:\` 与 POSIX 根 `/` 除外)
|
|
94
|
+
// - 保留 UNC 前缀 `\\`
|
|
95
|
+
//
|
|
96
|
+
// **不折大小写**:这是展示形态,用户看到的名字必须和他磁盘上的一致。
|
|
97
|
+
// 非法输入(非字符串 / 空串 / 含 NUL)返回 null,由调用方决定怎么回话。
|
|
98
|
+
//
|
|
99
|
+
// **不解析 `.` / `..`**:那需要文件系统(或至少一套纯文本折叠规则),归 resolveDirIdentity
|
|
100
|
+
// 的 realpath 管。这里不做的后果是**失败方向安全**的 —— `D:\项目A\sub\..` 会原样留下来
|
|
101
|
+
// 成为一条永远命中不了的绑定(会话 cwd 由宿主给出,永远是干净形态),而不是错命中
|
|
102
|
+
// 别的目录。反过来若在这里用文本规则折叠 `..`,`D:\项目A\sub\..` 会被折成 `D:\项目A`,
|
|
103
|
+
// 而 `sub` 是符号链接时这个折叠结果是**错的**。
|
|
104
|
+
export function normalizeDirPath(raw, options) {
|
|
105
|
+
if (typeof raw !== 'string') return null
|
|
106
|
+
const text = raw.trim()
|
|
107
|
+
if (text === '' || text.includes('\0')) return null
|
|
108
|
+
|
|
109
|
+
const { sep } = resolveOptions(options)
|
|
110
|
+
|
|
111
|
+
let prefix = ''
|
|
112
|
+
let body = text
|
|
113
|
+
// UNC 前缀先摘出来,否则下面「折叠连续分隔符」会把 `\\server` 折成 `\server`。
|
|
114
|
+
//
|
|
115
|
+
// 只在 Windows 分隔符下识别:POSIX 的 `//` 按规矩等同于 `/`(路径里的双斜杠是
|
|
116
|
+
// 实现定义行为,没有 UNC 语义),若在这里也当成 UNC,`//` 会被原样保留下来,
|
|
117
|
+
// 于是它与 `/` 成了两条不同的绑定。
|
|
118
|
+
if (sep === '\\') {
|
|
119
|
+
const uncMatch = /^[\\/]{2,}/.exec(body)
|
|
120
|
+
if (uncMatch) {
|
|
121
|
+
prefix = sep + sep
|
|
122
|
+
body = body.slice(uncMatch[0].length)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
body = body.replace(/[\\/]+/g, sep)
|
|
127
|
+
|
|
128
|
+
// 去尾分隔符。循环而不是一次正则:`D:\项目A\\` 折叠后仍可能留下一个尾分隔符。
|
|
129
|
+
while (body.length > 1 && body.endsWith(sep)) {
|
|
130
|
+
body = body.slice(0, -1)
|
|
131
|
+
}
|
|
132
|
+
// 盘根去掉尾分隔符后会变成 `D:`,那不是合法路径形态,补回来
|
|
133
|
+
if (/^[A-Za-z]:$/.test(body)) body += sep
|
|
134
|
+
|
|
135
|
+
const out = prefix + body
|
|
136
|
+
return out === '' ? null : out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 比较键:在展示形态之上再折一次大小写(仅 Windows 语义下)。
|
|
140
|
+
// 单独一个函数是为了让「存原样、比折叠」这条规则只有一处实现 —— 任何直接拿
|
|
141
|
+
// 展示形态去比的地方都会在 `D:\项目A` 与 `d:\项目a` 上得出错误结论。
|
|
142
|
+
export function compareKey(path, options) {
|
|
143
|
+
if (typeof path !== 'string') return ''
|
|
144
|
+
const { caseInsensitive } = resolveOptions(options)
|
|
145
|
+
return caseInsensitive ? path.toLowerCase() : path
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 解析目录的真实身份(解析符号链接 / Junction)。
|
|
149
|
+
//
|
|
150
|
+
// 目标不存在(目录被删、网络盘离线、权限不足)时**回落到规范化形态**,绝不抛错:
|
|
151
|
+
// 绑定表里存着一条指向已拔掉的移动硬盘的规则,不该让整个设置页或装配流程崩掉。
|
|
152
|
+
//
|
|
153
|
+
// realpathFn 可注入(默认 node:fs 的 realpath),使本函数能在离线断言里被确定性地测试。
|
|
154
|
+
export async function resolveDirIdentity(raw, options, realpathFn) {
|
|
155
|
+
const normalized = normalizeDirPath(raw, options)
|
|
156
|
+
if (!normalized) return null
|
|
157
|
+
|
|
158
|
+
const run = realpathFn || realpath
|
|
159
|
+
try {
|
|
160
|
+
const real = await run(normalized)
|
|
161
|
+
return normalizeDirPath(real, options) || normalized
|
|
162
|
+
} catch {
|
|
163
|
+
return normalized
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ==================== 前缀匹配 ====================
|
|
168
|
+
|
|
169
|
+
// 目录 candidate 是否落在绑定项 bound 的子树内。
|
|
170
|
+
//
|
|
171
|
+
// 这是整个功能的核心判据,写成 `candidate === bound || candidate.startsWith(bound + sep)`:
|
|
172
|
+
// · 前半段处理「恰好就是这个目录」;
|
|
173
|
+
// · 后半段的 `+ sep` 是**必须**的 —— 少了它,`D:\项目A` 会命中 `D:\项目AB`,
|
|
174
|
+
// 而这两个目录在磁盘上毫无关系。
|
|
175
|
+
//
|
|
176
|
+
// bound 以 sep 结尾时(盘根 `D:\`、POSIX 根 `/`)不再重复追加,否则会拼出 `D:\\`。
|
|
177
|
+
export function pathMatches(candidate, bound, options) {
|
|
178
|
+
const candidateKey = compareKey(candidate, options)
|
|
179
|
+
const boundKey = compareKey(bound, options)
|
|
180
|
+
if (candidateKey === '' || boundKey === '') return false
|
|
181
|
+
if (candidateKey === boundKey) return true
|
|
182
|
+
|
|
183
|
+
const { sep } = resolveOptions(options)
|
|
184
|
+
const prefix = boundKey.endsWith(sep) ? boundKey : boundKey + sep
|
|
185
|
+
return candidateKey.startsWith(prefix)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 列出命中 candidate 的全部绑定,**按具体程度从高到低**(最长前缀优先)。
|
|
189
|
+
//
|
|
190
|
+
// 返回数组而不是单个结果,是因为上层还需要「最近的一条绑了个已不存在的人设」这种
|
|
191
|
+
// 中间信息:它必须回落到父级绑定而不是直接掉到全局,而 UI 又要能把这层说明白。
|
|
192
|
+
export function matchBindings(cwd, bindings, options) {
|
|
193
|
+
const candidate = normalizeDirPath(cwd, options)
|
|
194
|
+
if (!candidate) return []
|
|
195
|
+
|
|
196
|
+
const table = bindings && typeof bindings === 'object' && !Array.isArray(bindings) ? bindings : {}
|
|
197
|
+
const hits = []
|
|
198
|
+
for (const dir of Object.keys(table)) {
|
|
199
|
+
const bound = normalizeDirPath(dir, options)
|
|
200
|
+
if (!bound) continue
|
|
201
|
+
if (!pathMatches(candidate, bound, options)) continue
|
|
202
|
+
hits.push({ dir, persona: table[dir], depth: compareKey(bound, options).length })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 同样长时用目录名兜底排序,保证结果稳定(同一份绑定表任何时候都给出同一顺序)
|
|
206
|
+
hits.sort((a, b) => (b.depth - a.depth) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
|
|
207
|
+
return hits
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 三层解析里「目录绑定」这一层的结论。
|
|
211
|
+
//
|
|
212
|
+
// 返回 null 表示没有任何可用绑定(回落全局);返回对象时:
|
|
213
|
+
// · dir / persona 是真正生效的那条;
|
|
214
|
+
// · shadowed 是命中了、但因为人设已不存在而被跳过的更具体绑定 ——
|
|
215
|
+
// 它们不生效(见 §4.2),但 UI 必须把它们说出来,否则用户在子目录里
|
|
216
|
+
// 看到父级的人设,只会以为是 bug。
|
|
217
|
+
//
|
|
218
|
+
// 失效引用**不自动删除**:用户可能只是临时删了那个预设、打算重命名回来。
|
|
219
|
+
export function resolvePersonaForCwd(cwd, bindings, userPersonas, options) {
|
|
220
|
+
const hits = matchBindings(cwd, bindings, options)
|
|
221
|
+
const shadowed = []
|
|
222
|
+
|
|
223
|
+
for (const hit of hits) {
|
|
224
|
+
if (resolvePersona(hit.persona, userPersonas)) {
|
|
225
|
+
return { dir: hit.dir, persona: hit.persona, shadowed }
|
|
226
|
+
}
|
|
227
|
+
shadowed.push({ dir: hit.dir, persona: hit.persona, reason: 'missing-persona' })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ==================== 绑定表校验 ====================
|
|
234
|
+
|
|
235
|
+
// 校验并归一化一张绑定表(读侧一律当不可信输入)。
|
|
236
|
+
//
|
|
237
|
+
// 逐条校验,**坏条目丢弃并计数**而不是整表拒绝:一张表里混进一行手改坏的记录,
|
|
238
|
+
// 不该让其余 20 条绑定一起失效。丢弃的原因一并返回,供 UI 显式标注。
|
|
239
|
+
//
|
|
240
|
+
// 返回 { bindings, dropped },bindings 的键已是规范化后的展示形态。
|
|
241
|
+
export function sanitizeBindings(raw, options) {
|
|
242
|
+
const dropped = []
|
|
243
|
+
const bindings = {}
|
|
244
|
+
|
|
245
|
+
if (raw === undefined || raw === null) return { bindings, dropped }
|
|
246
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
247
|
+
dropped.push({ dir: null, reason: 'not-object' })
|
|
248
|
+
return { bindings, dropped }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let count = 0
|
|
252
|
+
for (const key of Object.keys(raw)) {
|
|
253
|
+
if (FORBIDDEN_KEYS.has(key)) {
|
|
254
|
+
dropped.push({ dir: key, reason: 'reserved-key' })
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const rawDir = key
|
|
259
|
+
if (rawDir.length > MAX_PATH_LENGTH) {
|
|
260
|
+
dropped.push({ dir: rawDir, reason: 'path-too-long' })
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
const dir = normalizeDirPath(rawDir, options)
|
|
264
|
+
if (!dir) {
|
|
265
|
+
dropped.push({ dir: rawDir, reason: 'invalid-path' })
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (!isAbsoluteDirPath(dir)) {
|
|
269
|
+
dropped.push({ dir: rawDir, reason: 'not-absolute' })
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const persona = sanitizePersonaName(raw[key])
|
|
274
|
+
if (!persona) {
|
|
275
|
+
dropped.push({ dir, reason: 'invalid-persona' })
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 同一个目录在规范化之后可能撞车(`D:\项目A\` 与 `D:/项目A`)。后出现的覆盖先出现的,
|
|
280
|
+
// 但两条都算「读到了」——这不是损坏,只是同一条绑定的两种写法。
|
|
281
|
+
if (!Object.prototype.hasOwnProperty.call(bindings, dir)) {
|
|
282
|
+
if (count >= MAX_BINDINGS) {
|
|
283
|
+
dropped.push({ dir, reason: 'too-many' })
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
count++
|
|
287
|
+
}
|
|
288
|
+
bindings[dir] = persona
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { bindings, dropped }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 解析整个绑定文件的内容(含版本检查)。
|
|
295
|
+
//
|
|
296
|
+
// 返回 { bindings, dropped, failure }:failure 是「文件整体不可用」的一句话说明,
|
|
297
|
+
// 与「个别条目被丢弃」(dropped)是两件事 —— 前者应当以空表继续并在 UI 上报出来,
|
|
298
|
+
// 后者只是标注,功能照常。
|
|
299
|
+
export function parseBindingsDocument(raw, options) {
|
|
300
|
+
if (raw === undefined || raw === null) {
|
|
301
|
+
return { bindings: {}, dropped: [], failure: null }
|
|
302
|
+
}
|
|
303
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
304
|
+
return { bindings: {}, dropped: [], failure: '绑定文件的内容不是一个对象,已按空表处理。' }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const version = raw.version
|
|
308
|
+
if (version !== undefined && version !== BINDINGS_VERSION) {
|
|
309
|
+
return {
|
|
310
|
+
bindings: {},
|
|
311
|
+
dropped: [],
|
|
312
|
+
failure: `绑定文件的格式版本是 ${JSON.stringify(version)},本版本只认识 ${BINDINGS_VERSION},已按空表处理。`
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const { bindings, dropped } = sanitizeBindings(raw.bindings, options)
|
|
317
|
+
return { bindings, dropped, failure: null }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 写出用的文档形态。键**已在 sanitizeBindings 里规范化**,这里不再动它们,
|
|
321
|
+
// 因此「读进来 → 写回去」是幂等的。
|
|
322
|
+
export function serializeBindings(bindings) {
|
|
323
|
+
const table = bindings && typeof bindings === 'object' && !Array.isArray(bindings) ? bindings : {}
|
|
324
|
+
const out = {}
|
|
325
|
+
for (const key of Object.keys(table)) {
|
|
326
|
+
if (FORBIDDEN_KEYS.has(key)) continue
|
|
327
|
+
out[key] = table[key]
|
|
328
|
+
}
|
|
329
|
+
return { version: BINDINGS_VERSION, bindings: out }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ==================== 写入前的准入检查 ====================
|
|
333
|
+
|
|
334
|
+
// 检查一次「新增 / 改绑」请求是否可以写入,返回 null 表示可以,
|
|
335
|
+
// 否则返回一句可直接展示给用户的原因。
|
|
336
|
+
//
|
|
337
|
+
// 三类拒绝,每一类都对应一个真实会被问出来的问题:
|
|
338
|
+
// · 路径非法 / 非绝对 —— 相对路径的绑定永远命中不了任何会话;
|
|
339
|
+
// · 路径过长 —— 见 MAX_PATH_LENGTH 的注释;
|
|
340
|
+
// · 人设不存在 —— 允许写入的话,用户会把一个 typo 当成「绑定生效了」,
|
|
341
|
+
// 然后在目录里等一个永远不会来的人设。**内置人设是合法目标**(不可删但可绑)。
|
|
342
|
+
export function bindingRefusal(dir, persona, userPersonas) {
|
|
343
|
+
if (typeof dir !== 'string' || dir.length > MAX_PATH_LENGTH) {
|
|
344
|
+
return `目录路径过长(上限 ${MAX_PATH_LENGTH} 字符),未写入。`
|
|
345
|
+
}
|
|
346
|
+
if (!isAbsoluteDirPath(dir)) {
|
|
347
|
+
return '需要一个绝对路径,相对路径的绑定不会命中任何会话。'
|
|
348
|
+
}
|
|
349
|
+
// ★ 先归一化再查库:sanitizePersonaName 会把两侧空白 trim 掉,而查库要拿
|
|
350
|
+
// trim 之后的名字。直接拿原串去查的话,`我的自定义人设 `(末尾一个空格)
|
|
351
|
+
// 会在第一关通过、在第三关被判「不存在」—— 拒绝理由与真实原因不符,
|
|
352
|
+
// 用户看到的是一句自相矛盾的话(名字合法,却说这个名字不存在)。
|
|
353
|
+
const name = sanitizePersonaName(persona)
|
|
354
|
+
if (name === null) {
|
|
355
|
+
return `人设名不合法(1-${PERSONA_NAME_MAX} 个字符,且不能是保留字)。`
|
|
356
|
+
}
|
|
357
|
+
if (!resolvePersona(name, userPersonas)) {
|
|
358
|
+
return `人设「${name}」不存在,未写入绑定。`
|
|
359
|
+
}
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 把一次写入请求归一成可直接进表的形态(键与值都规范化),返回 null 表示不可写入。
|
|
364
|
+
//
|
|
365
|
+
// 与 bindingRefusal 分开是因为**两者的消费者不同**:refusal 只回答「能不能写」,
|
|
366
|
+
// 本函数回答「写什么」。合并成一个函数的后果是调用方必然自己再 trim 一遍值 ——
|
|
367
|
+
// 而那正是「拒绝时用的是 trim 后的名字、写进表里的是原串」这类不一致的来源。
|
|
368
|
+
export function normalizeBindingEntry(dir, persona, userPersonas, options) {
|
|
369
|
+
const normalizedDir = normalizeDirPath(dir, options)
|
|
370
|
+
const name = sanitizePersonaName(persona)
|
|
371
|
+
if (!normalizedDir || name === null) return null
|
|
372
|
+
if (bindingRefusal(normalizedDir, name, userPersonas)) return null
|
|
373
|
+
return { dir: normalizedDir, persona: name }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ==================== 文件路径与读写 ====================
|
|
377
|
+
|
|
378
|
+
// 绑定文件的位置:与配置文件**同目录**,但不从 `$DSH_HOME` 直接拼。
|
|
379
|
+
//
|
|
380
|
+
// 由 configPath() 推导而不是各自读一遍环境变量,是为了让「配置在哪、绑定就在哪」
|
|
381
|
+
// 成为结构事实 —— 测试里把配置指到临时目录时,绑定文件会自动跟着走,
|
|
382
|
+
// 不会出现「断言改了一份配置、实际写了另一份绑定」这种静默跑偏。
|
|
383
|
+
//
|
|
384
|
+
// 用字符串切分而不是 node:path 的 dirname/join:那两个函数**按运行平台**解析,
|
|
385
|
+
// 于是在 Windows 上处理 `/home/u/.dsh/soul-config.json` 会得到 `\home\u\...`,
|
|
386
|
+
// 断言随即无法在同一台机器上验证两套路径语义(verify-bindings 就是这么红的)。
|
|
387
|
+
// 与 lib/history.mjs 的 historyPathFor 同款写法:只做一次「最后一个分隔符」切分。
|
|
388
|
+
export function bindingsPathFor(configFilePath) {
|
|
389
|
+
if (typeof configFilePath !== 'string' || configFilePath.trim() === '') return null
|
|
390
|
+
const cut = Math.max(configFilePath.lastIndexOf('/'), configFilePath.lastIndexOf('\\'))
|
|
391
|
+
if (cut < 0) return 'soul-bindings.json'
|
|
392
|
+
return `${configFilePath.slice(0, cut + 1)}soul-bindings.json`
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 读取绑定文件。
|
|
396
|
+
//
|
|
397
|
+
// 与配置读取(lib/store.mjs 的 readConfigFile)复用同一套原语,因此「不存在」
|
|
398
|
+
// 「读不出来」「不是合法 JSON」三种情况的区分方式完全一致。
|
|
399
|
+
//
|
|
400
|
+
// 关键差别在**回退策略**:配置损坏时写入路径会拒绝保存(避免覆盖用户数据),
|
|
401
|
+
// 但绑定损坏时这里直接以空表继续 —— 绑定是环境事实,不是用户唯一的数据副本,
|
|
402
|
+
// 而且损坏时 moveAside 已经把原文件挪到 `.corrupt` 留给用户了。
|
|
403
|
+
//
|
|
404
|
+
// 返回 { bindings, dropped, failure, exists, movedAside }。
|
|
405
|
+
export async function readBindingsFile(filePath, options) {
|
|
406
|
+
const { value, exists, failure } = await readConfigFile(filePath)
|
|
407
|
+
|
|
408
|
+
if (failure) {
|
|
409
|
+
// 损坏的文件移开(而非仅复制):不动它的话,下一次读取仍判定为损坏,
|
|
410
|
+
// 用户除手删文件外没有任何出路 —— 这正是 moveAsideConfigFile 存在的理由。
|
|
411
|
+
const movedAside = await moveAsideConfigFile(filePath)
|
|
412
|
+
const backup = movedAside ? `(原文件已备份到 ${movedAside})` : ''
|
|
413
|
+
return {
|
|
414
|
+
bindings: {},
|
|
415
|
+
dropped: [],
|
|
416
|
+
failure: `${failure.message}${backup}`,
|
|
417
|
+
exists,
|
|
418
|
+
movedAside: movedAside || null
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const parsed = parseBindingsDocument(value, options)
|
|
423
|
+
return { ...parsed, exists, movedAside: null }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 原子写出绑定文件(复用 writeConfigFile 的临时文件 + rename 语义)。
|
|
427
|
+
export async function writeBindingsFile(filePath, bindings) {
|
|
428
|
+
await writeConfigFile(filePath, serializeBindings(bindings))
|
|
429
|
+
return serializeBindings(bindings)
|
|
430
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -30,7 +30,34 @@ export const DEFAULT_CONFIG = {
|
|
|
30
30
|
trailEnabled: true,
|
|
31
31
|
trailColor: '#679EFE',
|
|
32
32
|
trailSpeed: 'slow',
|
|
33
|
-
trailWidth: 'thin'
|
|
33
|
+
trailWidth: 'thin',
|
|
34
|
+
// 变更记录保留条数(压实后保多少条)。默认 200 与 lib/history.mjs 的
|
|
35
|
+
// HISTORY_MAX_ENTRIES_DEFAULT 一致 —— 两处不能各自漂移,由 verify-config 钉住。
|
|
36
|
+
historyMaxEntries: 200,
|
|
37
|
+
// 工作区级人设覆盖:`{ version, scopes: { [workspaceId]: {...} } }`。
|
|
38
|
+
//
|
|
39
|
+
// 默认值是 `undefined`(键**存在但无值**):这样它既是 DEFAULT_CONFIG 的一员
|
|
40
|
+
// (字段全集对账、写盘时不会因为「不在默认配置里」被当成未知字段丢掉),
|
|
41
|
+
// 又不会在用户没设过工作区时把 `workspaces: undefined` 写进 JSON
|
|
42
|
+
// (JSON.stringify 会省略 undefined 值,落盘后该键自然缺席)。
|
|
43
|
+
//
|
|
44
|
+
// 为什么它属于「配置」而不是 sidecar:工作区覆盖低频、天然有上限、用户在意它的
|
|
45
|
+
// 变更历史(0.8.0 的审计能追)。Session 覆盖是**状态**(高频、无上限、随会话消失),
|
|
46
|
+
// 那才需要 sidecar。校验与合并见 lib/workspace-scope.mjs。
|
|
47
|
+
workspaces: undefined,
|
|
48
|
+
// 最近一次「使用预设」时用户实际点的预设名(0.9.0-p)。
|
|
49
|
+
//
|
|
50
|
+
// 为什么必须记名字:★ 标记原先靠**字段反查**(findActivePersonaName 拿当前配置
|
|
51
|
+
// 去比每个预设,personaMatches 是部分匹配,多个预设声明相同字段时都命中,
|
|
52
|
+
// 然后按名称排序取首个)。实测:自建「游戏开发与策划专家」只声明 5 个维度
|
|
53
|
+
// (是内置「自驱型协作者」的子集),点「自驱型协作者」的应用写完 6 个维度后,
|
|
54
|
+
// 子集也命中 ⇒ 两个都命中 ⇒ 名称排序让自建的抢先 ⇒ **★ 标在了没点的卡上**
|
|
55
|
+
// (配置本身是对的,模型用的确实是所点预设的字段 —— 错的只是名字标注)。
|
|
56
|
+
//
|
|
57
|
+
// 与会话层的 personaName(sidecar 里的)同一个思路:记名字把"猜"变成"知道"。
|
|
58
|
+
// null = 从没用过「使用预设」(或手改过配置后已无法对应到某个预设)。
|
|
59
|
+
// 校验与清空见 index.mjs 的 applyPersonaEntry / sanitizeConfig。
|
|
60
|
+
activePersonaName: null
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
// 合法的回复风格和语调取值(供工具参数校验)
|
|
@@ -59,6 +86,26 @@ export const TRAIL_WIDTH_VALUES = ['thin', 'normal', 'thick']
|
|
|
59
86
|
// 与 DEFAULT_CONFIG 的默认值(#679EFE)一致,保证 dirty 比对稳定
|
|
60
87
|
export const TRAIL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
|
|
61
88
|
|
|
89
|
+
// ==================== 变更记录条数 ====================
|
|
90
|
+
|
|
91
|
+
// 变更记录保留条数的合法范围。下限 50:再小的话撤销窗口薄到「上一轮就没了」,
|
|
92
|
+
// 记录作为审计的价值大打折扣;上限 500:条目只存变化字段(实测单条几十字节),
|
|
93
|
+
// 500 条也远在 MAX_BYTES(512KB) 之内,再大只是让 UI 列表变长,没有收益。
|
|
94
|
+
export const HISTORY_MAX_ENTRIES_MIN = 50
|
|
95
|
+
export const HISTORY_MAX_ENTRIES_MAX = 500
|
|
96
|
+
export const HISTORY_MAX_ENTRIES_DEFAULT = 200
|
|
97
|
+
|
|
98
|
+
// 夹取到合法范围。非数字 / 非有限 / 非整数返回 null(调用方决定是回退默认值还是报错)。
|
|
99
|
+
//
|
|
100
|
+
// 类型检查必须在转换**之前**:早先写成 Number(value) 再判 isInteger,于是 null / '' /
|
|
101
|
+
// false / [] 统统被 Number() 转成 0,再夹取成下限 50 —— 配置里一处脏数据会安静地
|
|
102
|
+
// 把撤销窗口缩到最小档,而「变小」恰好表现为「老记录不见了」,正是最不该静默的那种错。
|
|
103
|
+
// 字符串同理不做隐式转换:收到字符串是调用方的 bug,不该被当成一个数字接受。
|
|
104
|
+
export function clampHistoryMaxEntries(value) {
|
|
105
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null
|
|
106
|
+
return Math.min(Math.max(value, HISTORY_MAX_ENTRIES_MIN), HISTORY_MAX_ENTRIES_MAX)
|
|
107
|
+
}
|
|
108
|
+
|
|
62
109
|
// 文本字段长度上限:防止超长文本撑爆 system prompt(同时缩小提示词注入面)。
|
|
63
110
|
// 超限一律拒绝、不做静默截断,避免改写用户输入。
|
|
64
111
|
export const FIELD_LIMITS = {
|
|
@@ -180,6 +227,13 @@ export function migrateConfig(raw) {
|
|
|
180
227
|
} else {
|
|
181
228
|
config.trailColor = config.trailColor.toUpperCase()
|
|
182
229
|
}
|
|
230
|
+
// 变更记录条数:非整数 / 超范围夹取到合法区间,脏数据回退默认值
|
|
231
|
+
{
|
|
232
|
+
const clamped = clampHistoryMaxEntries(config.historyMaxEntries)
|
|
233
|
+
config.historyMaxEntries = clamped === null
|
|
234
|
+
? DEFAULT_CONFIG.historyMaxEntries
|
|
235
|
+
: clamped
|
|
236
|
+
}
|
|
183
237
|
// 人设预设库归一化:剔除危险键与非对象条目(磁盘无该字段时保持缺席)
|
|
184
238
|
if ('personas' in config) config.personas = normalizePersonas(config.personas)
|
|
185
239
|
return config
|
|
@@ -237,6 +291,15 @@ export function sanitizeConfig(raw) {
|
|
|
237
291
|
}
|
|
238
292
|
}
|
|
239
293
|
|
|
294
|
+
if ('historyMaxEntries' in raw) {
|
|
295
|
+
const clamped = clampHistoryMaxEntries(raw.historyMaxEntries)
|
|
296
|
+
// 与光轨颜色不同策略:夹取而不是拒绝。理由:这是容量参数,不是用户内容——
|
|
297
|
+
// 「我要 30 条」被夹成 50 条是可解释的放宽,而报错会让用户面对一个
|
|
298
|
+
// 「为什么不能存少一点」的死胡同。0 / 负数 / 小数同样夹取。
|
|
299
|
+
if (clamped !== null) patch.historyMaxEntries = clamped
|
|
300
|
+
else errors.historyMaxEntries = `必须是 ${HISTORY_MAX_ENTRIES_MIN} 到 ${HISTORY_MAX_ENTRIES_MAX} 之间的整数`
|
|
301
|
+
}
|
|
302
|
+
|
|
240
303
|
for (const key of TEXT_FIELDS) {
|
|
241
304
|
if (!(key in raw)) continue
|
|
242
305
|
const value = raw[key]
|
|
@@ -276,5 +339,23 @@ export function sanitizeConfig(raw) {
|
|
|
276
339
|
}
|
|
277
340
|
}
|
|
278
341
|
|
|
342
|
+
// activePersonaName:最近一次「使用预设」时用户实际点的名字(0.9.0-p)。
|
|
343
|
+
// 只是给人看的注解(★ 标记的依据),不进提示词 —— 所以不在 TEXT_FIELDS /
|
|
344
|
+
// ENUM_CHECKS 里,单独校验。null 表示「没有对应到某个预设」(清空)。
|
|
345
|
+
if ('activePersonaName' in raw) {
|
|
346
|
+
const value = raw.activePersonaName
|
|
347
|
+
if (value === null) {
|
|
348
|
+
patch.activePersonaName = null
|
|
349
|
+
} else if (typeof value === 'string') {
|
|
350
|
+
const text = value.trim()
|
|
351
|
+
// 上限与预设名一致(64 字符,见 FORBIDDEN_PERSONA_NAMES 的注释)
|
|
352
|
+
if (text === '') patch.activePersonaName = null
|
|
353
|
+
else if (text.length <= 64) patch.activePersonaName = text
|
|
354
|
+
else errors.activePersonaName = '长度超过上限 64 字符'
|
|
355
|
+
} else {
|
|
356
|
+
errors.activePersonaName = '必须为字符串或 null'
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
279
360
|
return { patch, errors }
|
|
280
361
|
}
|