dsh-anima 0.0.0-stage → 2.2.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.
- package/CHANGELOG.md +1009 -0
- package/LICENSE +21 -0
- package/README.md +1015 -2
- package/README_EN.md +1000 -0
- package/assets/icon.svg +30 -0
- package/client/index.mjs +6295 -0
- package/cordis.patch.yml +3 -0
- package/index.mjs +3097 -0
- package/lib/audit.mjs +126 -0
- package/lib/config.mjs +617 -0
- package/lib/decay.mjs +131 -0
- package/lib/import.mjs +408 -0
- package/lib/injection.mjs +280 -0
- package/lib/memory-audit.mjs +262 -0
- package/lib/memory.mjs +703 -0
- package/lib/patrol.mjs +231 -0
- package/lib/presets.mjs +310 -0
- package/lib/prompt.mjs +416 -0
- package/lib/quality.mjs +718 -0
- package/lib/retrospect.mjs +296 -0
- package/lib/sessions.mjs +174 -0
- package/lib/store.mjs +755 -0
- package/lib/wording.mjs +139 -0
- package/lib/workspace.mjs +168 -0
- package/package.json +72 -4
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// lib/config.mjs — dsh-anima 配置层(纯函数,无外部依赖)
|
|
2
|
+
//
|
|
3
|
+
// 定位(用户 m03846 拍板「A 自建补齐」):
|
|
4
|
+
// 本插件自持全部人设字段,不依赖任何其它插件。字段设计对照某产品设置页
|
|
5
|
+
// 截图(「个性化」页 + 「记忆与进化」页)与某同类人设插件 0.7.0 的字段集,
|
|
6
|
+
// 但代码为自写,不吸收上游源码(字段名/界面布局属思想,不受版权保护)。
|
|
7
|
+
//
|
|
8
|
+
// 三类能力:
|
|
9
|
+
// ① 助手自身身份(agentName/agentRole)+ 用户背景(userOccupation/userBio)
|
|
10
|
+
// ② 按工作区两级覆盖(field-level merge:工作区只覆盖它明确声明的字段)
|
|
11
|
+
// ③ 带待审缓冲区的长期记忆
|
|
12
|
+
//
|
|
13
|
+
// 本模块被 index.mjs(host)与 scripts/verify-core.mjs 共用。
|
|
14
|
+
|
|
15
|
+
import { LANGUAGE_WORDING, REPLY_LENGTH_WORDING, STYLE_WORDING, TRAIT_WORDING, translateWording } from './wording.mjs'
|
|
16
|
+
|
|
17
|
+
// ==================== 全局配置 ====================
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_CONFIG = {
|
|
20
|
+
enabled: true,
|
|
21
|
+
|
|
22
|
+
// ① 助手自身身份(某同类人设插件无此字段:其 nickname 语义是「用户的名字」)。
|
|
23
|
+
agentName: '', // AI 的名字
|
|
24
|
+
agentRole: '', // AI 是谁 / 角色设定
|
|
25
|
+
|
|
26
|
+
// ② 用户背景。与 agentName 严格互斥:这一段描述的是用户,不是助手。
|
|
27
|
+
addressUserAs: '', // 回复时对用户的称呼
|
|
28
|
+
userOccupation: '', // 用户的职业 / 身份
|
|
29
|
+
userBio: '', // 关于用户的介绍
|
|
30
|
+
|
|
31
|
+
// ③ 回复风格与特质微调。**全部是自由文本**(用户 m06593 拍板:不要预设档位,
|
|
32
|
+
// 每个人的想法不一样)。默认值 = 原「专业严谨」档的文案,理由有两条:
|
|
33
|
+
// ① 行为不变 —— 改造前 enabled 时风格段必非空,模型不会退回到通用语气;
|
|
34
|
+
// ② 用户看到的不是一行空框,而是一句可以照着改的样板。
|
|
35
|
+
// 原档位没有消失,降级成设置页上的「一键填入」按钮(见 lib/wording.mjs)。
|
|
36
|
+
style: STYLE_WORDING.professional,
|
|
37
|
+
headingLists: TRAIT_WORDING.headingLists.default, // 空串 = 不约束(原 default 档)
|
|
38
|
+
emoji: TRAIT_WORDING.emoji.default,
|
|
39
|
+
tables: TRAIT_WORDING.tables.default,
|
|
40
|
+
replyLength: REPLY_LENGTH_WORDING.normal,
|
|
41
|
+
language: LANGUAGE_WORDING.zh,
|
|
42
|
+
|
|
43
|
+
// ④ 自由文本的补充指令
|
|
44
|
+
customInstructions: '',
|
|
45
|
+
|
|
46
|
+
// ⑤ 工具改人设是否要先经用户确认(对照某同类人设插件的 requireToolConfirmation)。
|
|
47
|
+
// 置 true 时改人设的工具不落盘,只返回一条待确认提议,等用户在对话里裁决。
|
|
48
|
+
//
|
|
49
|
+
// 默认**关**:多数用户第一次装插件时并不预期「模型帮我改人设」要过一次确认,
|
|
50
|
+
// 默认开着会在他们没要求的情况下多出一次往返。默认关的代价则由审计日志兜住 ——
|
|
51
|
+
// 每一次改动都会留下「谁、何时、把哪个字段改成了什么」,用户事后能看见。
|
|
52
|
+
// 两者是配套的:日志不能替代把关,把关也不该在用户没要求时强加。
|
|
53
|
+
//
|
|
54
|
+
// 与 trail* 一起**不参与工作区覆盖**:一个是安全开关,一组是界面偏好,
|
|
55
|
+
// 都不该因为「换个工作区」而悄悄换掉。
|
|
56
|
+
requireToolConfirmation: false,
|
|
57
|
+
|
|
58
|
+
// ⑥ 输入框光轨(对照某同类人设插件的 trail* 四项)
|
|
59
|
+
trailEnabled: true,
|
|
60
|
+
trailColor: '#679EFE',
|
|
61
|
+
trailSpeed: 'slow', // slow | normal | fast
|
|
62
|
+
trailWidth: 'thin', // thin | normal | thick
|
|
63
|
+
|
|
64
|
+
// ⑦ 用户自建的人设预设(内置预设不落盘,见 lib/presets.mjs)。
|
|
65
|
+
// 存进配置而非单独一张表:预设本就是配置的一部分,整表替换天然原子。
|
|
66
|
+
presets: [],
|
|
67
|
+
|
|
68
|
+
// ⑧ 工作区两级覆盖
|
|
69
|
+
workspaceOverridesEnabled: true,
|
|
70
|
+
workspaceFileDir: '.dsh-anima',
|
|
71
|
+
|
|
72
|
+
// ⑨ 记忆层
|
|
73
|
+
memory: {
|
|
74
|
+
enabled: true,
|
|
75
|
+
autoCapture: true, // 允许 Agent 通过 remember 工具投递候选
|
|
76
|
+
nightlyTriage: true, // 每晚自动整理到待审缓冲区
|
|
77
|
+
nightlyHour: 3, // 本地时区 0..23
|
|
78
|
+
maxPending: 200, // 缓冲区上限,超出按最早优先淘汰
|
|
79
|
+
maxInjected: 40, // 注入提示词的已采纳记忆条数上限
|
|
80
|
+
maxInjectedChars: 4000, // 注入字符预算(与条数取先到者)—— 只有条数约束时,40 条长记忆一样能挤爆上下文
|
|
81
|
+
notifyOnSessionStart: true,
|
|
82
|
+
// 默认把新记忆记在哪一层:global 处处生效;workspace 只在当前工作区生效。
|
|
83
|
+
// 默认 global 是刻意的:把「记忆动不动就少一条」作为默认体验,比跨工作区
|
|
84
|
+
// 污染更难排查 —— 用户主动选 workspace 才算知情。
|
|
85
|
+
defaultLayer: 'global',
|
|
86
|
+
// 回扫式复盘(2026-11-08 加):定期回读旧会话,捞出「用户当时说过、但没被
|
|
87
|
+
// 记下来」的话。默认**关闭**,理由见 lib/retrospect.mjs 文件头 —— 它要读
|
|
88
|
+
// 磁盘上的会话日志,是一个新开的口子,不能默认打开。
|
|
89
|
+
retrospectEnabled: false,
|
|
90
|
+
retrospectDays: 14 // 回扫窗口(天)。实测全部 53 个会话扫一遍约 3 秒,
|
|
91
|
+
// 单会话最慢 711ms(18.3MB 那个);窗口越小越稳,所以默认只看两周。
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// ⑩ 质检层(十二条判据 + 对账)
|
|
95
|
+
//
|
|
96
|
+
// 设计前提:**判定必须能离开我**。这四项都不问「模型觉得自己有没有敷衍」,
|
|
97
|
+
// 只检查文本里有没有该有的形状。凡是要模型自评的判据一律不放在这里 ——
|
|
98
|
+
// 那等于让被检查者兼任检查者。
|
|
99
|
+
quality: {
|
|
100
|
+
// 总开关。关掉时提示词段与 pre-step 机制检查同时停,不留半开的中间态。
|
|
101
|
+
enabled: true,
|
|
102
|
+
// 对账触发档位:off / three(三档全开)/ five(三档 + 每类各写一行)
|
|
103
|
+
ledgerLevel: 'three',
|
|
104
|
+
// 严格模式:额外启用 R08 / R11 / R12(误报率较高,但用户要求全开)
|
|
105
|
+
strict: true,
|
|
106
|
+
// 机制检查:每步开始前用代码读上一轮的回答,命中就注入提醒。
|
|
107
|
+
// 这是本层唯一**不依赖我自述**的环节:判词由代码给出,我只负责照做。
|
|
108
|
+
preStepCheck: true,
|
|
109
|
+
// 一条提醒里最多列几条问题(超出的只说总数,否则提醒本身会挤爆上下文)
|
|
110
|
+
maxFindingsInReminder: 5
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 这里**刻意没有** CONFIG_VERSION。
|
|
115
|
+
//
|
|
116
|
+
// 曾经有过一个 `CONFIG_VERSION = 3`,但它全仓零引用(定义即孤岛),而且数字 3 与
|
|
117
|
+
// lib\store.mjs 的 DOMAIN_VERSION 撞在一起 —— 两个不同的东西共用一个数字,正是
|
|
118
|
+
// 「v1 还是 v3」口径混乱的来源之一。真正需要版本沿革的地方是**介质**(域),
|
|
119
|
+
// 沿革注释与常量都在 lib\store.mjs(DOMAIN_VERSION)。
|
|
120
|
+
// 配置字段的增删只影响 migrateConfig 的容错,不构成需要对外报告的版本。
|
|
121
|
+
|
|
122
|
+
// ==================== 枚举 ====================
|
|
123
|
+
//
|
|
124
|
+
// 人设的六个字段(style / headingLists / emoji / tables / replyLength / language)
|
|
125
|
+
// 现在**都不是枚举**,是自由文本;原来那份「键 → 文案」的表留在
|
|
126
|
+
// lib/wording.mjs,只被两处使用:迁移期把旧键翻译成文案、设置页的「一键填入」。
|
|
127
|
+
// 于是这里只剩**界面偏好**类的枚举 —— 光轨速率与粗细:它们是给 CSS 用的键
|
|
128
|
+
// (设置页要把值写进 data 属性让样式表选中),必须是封闭集合,
|
|
129
|
+
// 而且没有「每个用户想法不同」的空间(细一点还是粗一点,选项足够表达)。
|
|
130
|
+
|
|
131
|
+
// 记忆层级在这里定义(而非 lib/memory.mjs):memory.mjs 已从本模块导入,
|
|
132
|
+
// 反向依赖会成环。memory.mjs 从这里重新导出,对外仍是同一个常量。
|
|
133
|
+
export const MEMORY_LAYERS = ['global', 'workspace']
|
|
134
|
+
/**
|
|
135
|
+
* 对账档位。定义在这里而不是 lib/quality.mjs,理由同 MEMORY_LAYERS:
|
|
136
|
+
* quality.mjs 被 config.mjs 的下游(index.mjs)引用,反过来 import 会成环。
|
|
137
|
+
* quality.mjs 从这里重新导出,对外仍是同一个常量。
|
|
138
|
+
*/
|
|
139
|
+
export const LEDGER_LEVELS = ['off', 'three', 'five']
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 记忆类型。四种的「什么时候该失效」完全不同,所以必须分开:
|
|
143
|
+
* fact 事实(职业、技术栈、联系方式)—— 基本不衰,改了才覆盖
|
|
144
|
+
* preference 偏好(讨厌被替下结论)—— 半衰期很长,轻易不动
|
|
145
|
+
* stance 立场(当初为什么选 A 不选 B)—— 不衰,但「我改主意了」时要归档成「曾如此」
|
|
146
|
+
* event 事件(那天他说想放弃)—— 永不失效,但会归档;删掉它就是删掉发生过的事
|
|
147
|
+
*/
|
|
148
|
+
export const MEMORY_TYPES = ['fact', 'preference', 'stance', 'event']
|
|
149
|
+
export const DEFAULT_MEMORY_TYPE = 'fact'
|
|
150
|
+
|
|
151
|
+
/** 记忆生命周期状态(区别于 status 的裁决状态)。 */
|
|
152
|
+
export const MEMORY_STATES = ['current', 'superseded', 'contradicted', 'dropped']
|
|
153
|
+
export const DEFAULT_MEMORY_STATE = 'current'
|
|
154
|
+
// 光轨两项(对照某同类人设插件的 TRAIL_SPEED_VALUES / TRAIL_WIDTH_VALUES)
|
|
155
|
+
export const TRAIL_SPEED_VALUES = ['slow', 'normal', 'fast']
|
|
156
|
+
export const TRAIL_WIDTH_VALUES = ['thin', 'normal', 'thick']
|
|
157
|
+
/** 光轨颜色:只接受 #RRGGBB。某同类人设插件的前端用同一个正则做容错,这里作为落盘闸门。 */
|
|
158
|
+
export const TRAIL_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
|
|
159
|
+
export const TRAIL_COLOR_FALLBACK = '#679EFE'
|
|
160
|
+
|
|
161
|
+
/** 枚举字段 → 允许值。sanitize/migrate 共用,避免两处写岔。 */
|
|
162
|
+
export const ENUM_FIELDS = {
|
|
163
|
+
trailSpeed: TRAIL_SPEED_VALUES,
|
|
164
|
+
trailWidth: TRAIL_WIDTH_VALUES
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 曾经是枚举、v3 起改为自由文本的字段。
|
|
169
|
+
*
|
|
170
|
+
* 单独列出来,是因为迁移路径要分两步走:先看旧值是不是档位键(翻译成文案),
|
|
171
|
+
* 不是的话就当用户自己写的句子原样留下。若沿用 ENUM_FIELDS 的「命中白名单才收」,
|
|
172
|
+
* 这些字段的旧值会被整条丢掉,用户升级后风格悄悄变回默认 ——
|
|
173
|
+
* 而 ENUM_FIELDS 此刻已经不认识它们了(只含光轨两项)。
|
|
174
|
+
*/
|
|
175
|
+
export const FORMER_ENUM_FIELDS = ['style', 'headingLists', 'emoji', 'tables', 'replyLength', 'language']
|
|
176
|
+
|
|
177
|
+
// ==================== 字段边界 ====================
|
|
178
|
+
|
|
179
|
+
export const FIELD_LIMITS = {
|
|
180
|
+
agentName: 50,
|
|
181
|
+
agentRole: 800,
|
|
182
|
+
addressUserAs: 50,
|
|
183
|
+
userOccupation: 50,
|
|
184
|
+
userBio: 500,
|
|
185
|
+
// 六个「原枚举」字段自 v3 起是自由文本,上限按「写一段话」而不是「写一个键」来定。
|
|
186
|
+
// 风格段是语气的主描述,给得宽一些;其余五项是单项约束,一句话就够,
|
|
187
|
+
// 给 300 是因为确实有人会写「表格只用来说明对比,别用来摆放列表」这种带条件的说明。
|
|
188
|
+
style: 800,
|
|
189
|
+
headingLists: 300,
|
|
190
|
+
emoji: 300,
|
|
191
|
+
tables: 300,
|
|
192
|
+
replyLength: 300,
|
|
193
|
+
language: 300,
|
|
194
|
+
customInstructions: 2000
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const TEXT_FIELDS = ['agentName', 'agentRole', 'addressUserAs', 'userOccupation', 'userBio', 'customInstructions', ...FORMER_ENUM_FIELDS]
|
|
198
|
+
|
|
199
|
+
// 工作区覆盖文件里允许出现的字段。
|
|
200
|
+
// 注意不含 memory:记忆是全局资产,不按工作区分叉;也不含 trail*/requireToolConfirmation:
|
|
201
|
+
// 前者是界面偏好、后者是安全开关,都不该因为「换个工作区」而悄悄换掉。
|
|
202
|
+
//
|
|
203
|
+
// 与 TEXT_FIELDS 同集合是 v3 的结果而非巧合:人设字段全部改自由文本之后,
|
|
204
|
+
// 「能写的」与「能被工作区覆盖的」正好是同一批。共用一个数组,
|
|
205
|
+
// 免得将来加字段时改了一处、漏了另一处,而漏掉的那处不会报错、只会静默失效。
|
|
206
|
+
export const OVERRIDABLE_FIELDS = [...TEXT_FIELDS]
|
|
207
|
+
|
|
208
|
+
// 记忆候选文本上限(单条)
|
|
209
|
+
export const MEMORY_TEXT_MAX = 500
|
|
210
|
+
export const MEMORY_NOTE_MAX = 200
|
|
211
|
+
|
|
212
|
+
// 自建预设的结构上限(语义校验在 lib/presets.mjs,两边数值须一致)
|
|
213
|
+
export const PRESET_ARRAY_LIMIT = 50
|
|
214
|
+
export const PRESET_NAME_MAX = 40
|
|
215
|
+
|
|
216
|
+
// 禁用名(防原型污染)
|
|
217
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
218
|
+
|
|
219
|
+
// ==================== 校验 ====================
|
|
220
|
+
|
|
221
|
+
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
222
|
+
|
|
223
|
+
const clampInt = (v, min, max, fallback) => {
|
|
224
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
|
|
225
|
+
const n = Math.trunc(v)
|
|
226
|
+
if (n < min || n > max) return fallback
|
|
227
|
+
return n
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 校验外部传入的配置补丁。
|
|
232
|
+
* @returns {{patch: object, errors: Record<string,string>}}
|
|
233
|
+
* patch 仅含通过校验的字段;errors 非空时调用方应整单拒绝。
|
|
234
|
+
*/
|
|
235
|
+
export function sanitizeConfig(raw) {
|
|
236
|
+
const patch = {}
|
|
237
|
+
const errors = {}
|
|
238
|
+
|
|
239
|
+
if (!isPlainObject(raw)) return { patch, errors: { _: '配置必须是一个 JSON 对象' } }
|
|
240
|
+
|
|
241
|
+
if ('enabled' in raw) {
|
|
242
|
+
if (typeof raw.enabled === 'boolean') patch.enabled = raw.enabled
|
|
243
|
+
else errors.enabled = '必须为布尔值(true / false)'
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if ('workspaceOverridesEnabled' in raw) {
|
|
247
|
+
if (typeof raw.workspaceOverridesEnabled === 'boolean') patch.workspaceOverridesEnabled = raw.workspaceOverridesEnabled
|
|
248
|
+
else errors.workspaceOverridesEnabled = '必须为布尔值(true / false)'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ('workspaceFileDir' in raw) {
|
|
252
|
+
const text = typeof raw.workspaceFileDir === 'string' ? raw.workspaceFileDir.trim() : ''
|
|
253
|
+
if (!text) errors.workspaceFileDir = '不能为空'
|
|
254
|
+
// 只允许单段相对目录名,避免写到工作区之外
|
|
255
|
+
else if (/[\\/]/.test(text) || text === '..' || text === '.') errors.workspaceFileDir = '必须是单段目录名(不含路径分隔符)'
|
|
256
|
+
else if (text.length > 64) errors.workspaceFileDir = '长度超过上限 64 字符'
|
|
257
|
+
else patch.workspaceFileDir = text
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const key of TEXT_FIELDS) {
|
|
261
|
+
if (!(key in raw)) continue
|
|
262
|
+
const value = raw[key]
|
|
263
|
+
if (typeof value !== 'string') {
|
|
264
|
+
errors[key] = '必须为字符串'
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
const text = value.trim()
|
|
268
|
+
if (text.length > FIELD_LIMITS[key]) {
|
|
269
|
+
errors[key] = `长度超过上限 ${FIELD_LIMITS[key]} 字符(当前 ${text.length})`
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
patch[key] = text
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 枚举字段:只接受白名单内的值,写错必报错而不是静默回退。
|
|
276
|
+
// v3 起这里只剩光轨两项 —— 人设六字段已是自由文本,走上方的 TEXT_FIELDS 循环。
|
|
277
|
+
for (const [key, allowed] of Object.entries(ENUM_FIELDS)) {
|
|
278
|
+
if (!(key in raw)) continue
|
|
279
|
+
const value = raw[key]
|
|
280
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
281
|
+
errors[key] = `必须是 ${allowed.join(' / ')} 之一`
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
patch[key] = value
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 工具改人设的确认门槛 + 光轨总开关:布尔
|
|
288
|
+
for (const key of ['requireToolConfirmation', 'trailEnabled']) {
|
|
289
|
+
if (!(key in raw)) continue
|
|
290
|
+
if (typeof raw[key] === 'boolean') patch[key] = raw[key]
|
|
291
|
+
else errors[key] = '必须为布尔值(true / false)'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 光轨颜色:只收 #RRGGBB。前端 safeTrailColor() 会在渲染前再兜一次底,
|
|
295
|
+
// 但落盘闸门在这里 —— 脏值不该进配置,否则每次读出来都要重新判一遍。
|
|
296
|
+
if ('trailColor' in raw) {
|
|
297
|
+
const text = typeof raw.trailColor === 'string' ? raw.trailColor.trim() : ''
|
|
298
|
+
if (TRAIL_COLOR_PATTERN.test(text)) patch.trailColor = text.toUpperCase()
|
|
299
|
+
else errors.trailColor = '必须是 #RRGGBB 形式的十六进制颜色'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if ('memory' in raw) {
|
|
303
|
+
if (!isPlainObject(raw.memory)) {
|
|
304
|
+
errors.memory = '必须是一个对象'
|
|
305
|
+
} else {
|
|
306
|
+
const m = {}
|
|
307
|
+
const boolKeys = ['enabled', 'autoCapture', 'nightlyTriage', 'notifyOnSessionStart', 'retrospectEnabled']
|
|
308
|
+
for (const key of boolKeys) {
|
|
309
|
+
if (!(key in raw.memory)) continue
|
|
310
|
+
if (typeof raw.memory[key] === 'boolean') m[key] = raw.memory[key]
|
|
311
|
+
else errors[`memory.${key}`] = '必须为布尔值(true / false)'
|
|
312
|
+
}
|
|
313
|
+
if ('nightlyHour' in raw.memory) {
|
|
314
|
+
const v = raw.memory.nightlyHour
|
|
315
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 23) m.nightlyHour = Math.trunc(v)
|
|
316
|
+
else errors['memory.nightlyHour'] = '必须是 0..23 的整数'
|
|
317
|
+
}
|
|
318
|
+
if ('maxPending' in raw.memory) {
|
|
319
|
+
const v = raw.memory.maxPending
|
|
320
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= 5000) m.maxPending = Math.trunc(v)
|
|
321
|
+
else errors['memory.maxPending'] = '必须是 1..5000 的整数'
|
|
322
|
+
}
|
|
323
|
+
if ('maxInjected' in raw.memory) {
|
|
324
|
+
const v = raw.memory.maxInjected
|
|
325
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 500) m.maxInjected = Math.trunc(v)
|
|
326
|
+
else errors['memory.maxInjected'] = '必须是 0..500 的整数'
|
|
327
|
+
}
|
|
328
|
+
if ('maxInjectedChars' in raw.memory) {
|
|
329
|
+
const v = raw.memory.maxInjectedChars
|
|
330
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 100000) {
|
|
331
|
+
m.maxInjectedChars = Math.trunc(v)
|
|
332
|
+
} else errors['memory.maxInjectedChars'] = '必须是 0..100000 的整数'
|
|
333
|
+
}
|
|
334
|
+
if ('defaultLayer' in raw.memory) {
|
|
335
|
+
const v = raw.memory.defaultLayer
|
|
336
|
+
if (MEMORY_LAYERS.includes(v)) m.defaultLayer = v
|
|
337
|
+
else errors['memory.defaultLayer'] = `必须是 ${MEMORY_LAYERS.join(' / ')} 之一`
|
|
338
|
+
}
|
|
339
|
+
if ('retrospectDays' in raw.memory) {
|
|
340
|
+
const v = raw.memory.retrospectDays
|
|
341
|
+
// 上限 365 天:回扫是逐帧解压会话日志,窗口开太大等于每次触发都读几百 MB。
|
|
342
|
+
// 下限 1 天:0 会让 sinceMs 退化成「全量扫」,与用户写下的数字不符。
|
|
343
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= 365) m.retrospectDays = Math.trunc(v)
|
|
344
|
+
else errors['memory.retrospectDays'] = '必须是 1..365 的整数'
|
|
345
|
+
}
|
|
346
|
+
if (Object.keys(m).length > 0) patch.memory = m
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if ('presets' in raw) {
|
|
351
|
+
const { value, error } = validatePresetsStructurally(raw.presets)
|
|
352
|
+
if (error) errors.presets = error
|
|
353
|
+
else patch.presets = value
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if ('quality' in raw) {
|
|
357
|
+
if (!isPlainObject(raw.quality)) {
|
|
358
|
+
errors.quality = '必须是一个对象'
|
|
359
|
+
} else {
|
|
360
|
+
const q = {}
|
|
361
|
+
for (const key of ['enabled', 'strict', 'preStepCheck']) {
|
|
362
|
+
if (!(key in raw.quality)) continue
|
|
363
|
+
if (typeof raw.quality[key] === 'boolean') q[key] = raw.quality[key]
|
|
364
|
+
else errors[`quality.${key}`] = '必须为布尔值(true / false)'
|
|
365
|
+
}
|
|
366
|
+
if ('ledgerLevel' in raw.quality) {
|
|
367
|
+
const v = raw.quality.ledgerLevel
|
|
368
|
+
if (LEDGER_LEVELS.includes(v)) q.ledgerLevel = v
|
|
369
|
+
else errors['quality.ledgerLevel'] = `必须是 ${LEDGER_LEVELS.join(' / ')} 之一`
|
|
370
|
+
}
|
|
371
|
+
if ('maxFindingsInReminder' in raw.quality) {
|
|
372
|
+
const v = raw.quality.maxFindingsInReminder
|
|
373
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 1 && v <= 20) {
|
|
374
|
+
q.maxFindingsInReminder = Math.trunc(v)
|
|
375
|
+
} else errors['quality.maxFindingsInReminder'] = '必须是 1..20 的整数'
|
|
376
|
+
}
|
|
377
|
+
if (Object.keys(q).length > 0) patch.quality = q
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { patch, errors }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 把错误表渲染成一行人类可读文案。 */
|
|
385
|
+
export function formatFieldErrors(errors) {
|
|
386
|
+
return Object.entries(errors).map(([k, v]) => `${k}:${v}`).join(';')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 自建预设的**结构**校验(字段语义校验在 lib/presets.mjs,避免循环引用)。
|
|
391
|
+
* 这里只保证写进配置的东西结构可读:每条有非空 name 与纯字符串的 fields。
|
|
392
|
+
*/
|
|
393
|
+
function validatePresetsStructurally(raw) {
|
|
394
|
+
if (!Array.isArray(raw)) return { error: '必须是数组' }
|
|
395
|
+
if (raw.length > PRESET_ARRAY_LIMIT) return { error: `最多 ${PRESET_ARRAY_LIMIT} 条` }
|
|
396
|
+
const value = []
|
|
397
|
+
for (const [index, item] of raw.entries()) {
|
|
398
|
+
if (!isPlainObject(item)) return { error: `第 ${index + 1} 条不是对象` }
|
|
399
|
+
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
|
400
|
+
if (!name) return { error: `第 ${index + 1} 条缺少 name` }
|
|
401
|
+
if (name.length > PRESET_NAME_MAX) return { error: `第 ${index + 1} 条 name 超过 ${PRESET_NAME_MAX} 字符` }
|
|
402
|
+
const fields = {}
|
|
403
|
+
if (item.fields !== undefined) {
|
|
404
|
+
if (!isPlainObject(item.fields)) return { error: `第 ${index + 1} 条的 fields 不是对象` }
|
|
405
|
+
for (const [key, val] of Object.entries(item.fields)) {
|
|
406
|
+
if (FORBIDDEN_KEYS.has(key)) return { error: `第 ${index + 1} 条含禁用字段名 ${key}` }
|
|
407
|
+
if (typeof val !== 'string') return { error: `第 ${index + 1} 条的 ${key} 必须是字符串` }
|
|
408
|
+
}
|
|
409
|
+
Object.assign(fields, item.fields)
|
|
410
|
+
}
|
|
411
|
+
value.push({
|
|
412
|
+
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `custom:${name}`,
|
|
413
|
+
name,
|
|
414
|
+
description: typeof item.description === 'string' ? item.description.trim() : '',
|
|
415
|
+
fields,
|
|
416
|
+
custom: true,
|
|
417
|
+
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString()
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
return { value }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ==================== 迁移与归一 ====================
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 自建预设的浅归一(迁移期用):只保证结构可读,不判枚举合法性。
|
|
427
|
+
* 字段值全部收成字符串——非字符串直接丢弃,避免下游 prompt 拼装拿到对象。
|
|
428
|
+
*/
|
|
429
|
+
function normalizePresetsShallow(raw) {
|
|
430
|
+
const out = []
|
|
431
|
+
for (const item of raw) {
|
|
432
|
+
if (!isPlainObject(item)) continue
|
|
433
|
+
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
|
434
|
+
if (!name || name.length > 40) continue
|
|
435
|
+
const fields = {}
|
|
436
|
+
if (isPlainObject(item.fields)) {
|
|
437
|
+
for (const [key, value] of Object.entries(item.fields)) {
|
|
438
|
+
if (FORBIDDEN_KEYS.has(key)) continue
|
|
439
|
+
if (typeof value !== 'string') continue
|
|
440
|
+
const text = value.trim()
|
|
441
|
+
if (text) fields[key] = text
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
out.push({
|
|
445
|
+
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : `custom:${name}`,
|
|
446
|
+
name,
|
|
447
|
+
description: typeof item.description === 'string' ? item.description.trim() : '',
|
|
448
|
+
fields,
|
|
449
|
+
custom: true,
|
|
450
|
+
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString()
|
|
451
|
+
})
|
|
452
|
+
if (out.length >= 50) break
|
|
453
|
+
}
|
|
454
|
+
return out
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 深合并 memory 子对象并回退脏数据。 */
|
|
458
|
+
export function migrateConfig(raw) {
|
|
459
|
+
const base = { ...DEFAULT_CONFIG }
|
|
460
|
+
const input = isPlainObject(raw) ? raw : {}
|
|
461
|
+
const out = { ...base }
|
|
462
|
+
|
|
463
|
+
for (const key of ['enabled', 'workspaceOverridesEnabled']) {
|
|
464
|
+
if (typeof input[key] === 'boolean') out[key] = input[key]
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 只接受单段目录名(不含路径分隔符):它是拼在工作区路径后面的。
|
|
468
|
+
// 不做任何「旧默认值 → 新默认值」的归一 —— 插件未发布,没有需要兼容的存量配置。
|
|
469
|
+
if (typeof input.workspaceFileDir === 'string' && input.workspaceFileDir.trim()
|
|
470
|
+
&& !/[\\/]/.test(input.workspaceFileDir)) {
|
|
471
|
+
out.workspaceFileDir = input.workspaceFileDir.trim()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
for (const key of TEXT_FIELDS) {
|
|
475
|
+
if (typeof input[key] === 'string') {
|
|
476
|
+
const text = input[key].trim()
|
|
477
|
+
out[key] = text.length > FIELD_LIMITS[key] ? text.slice(0, FIELD_LIMITS[key]) : text
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 原枚举字段(v2 及更早)的键 → 文案。必须放在上面的 TEXT_FIELDS 循环**之后**:
|
|
482
|
+
// 那个循环已把旧键原样搬进 out,这里负责把 'professional' 换成对应的一句话。
|
|
483
|
+
// 两步分开写而不是让循环里分叉,是因为「搬运」与「翻译」是两件独立的事 ——
|
|
484
|
+
// 循环只回答「这个值该不该留」,翻译只回答「留哪个值」。
|
|
485
|
+
//
|
|
486
|
+
// 不命中的情况(用户自己写的句子)原样保留:这正是 v3 之后的主要形态。
|
|
487
|
+
for (const key of FORMER_ENUM_FIELDS) {
|
|
488
|
+
const translated = translateWording(key, out[key])
|
|
489
|
+
if (translated !== null) out[key] = translated
|
|
490
|
+
}
|
|
491
|
+
// 枚举字段:脏值静默回退到默认(迁移路径要宽容,与 sanitize 的严格形成对照)。
|
|
492
|
+
// v3 起这里只剩光轨两项。
|
|
493
|
+
for (const [key, allowed] of Object.entries(ENUM_FIELDS)) {
|
|
494
|
+
if (typeof input[key] === 'string' && allowed.includes(input[key])) out[key] = input[key]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 工具确认门槛 + 光轨:脏值静默回退(同上,迁移期宽容)
|
|
498
|
+
for (const key of ['requireToolConfirmation', 'trailEnabled']) {
|
|
499
|
+
if (typeof input[key] === 'boolean') out[key] = input[key]
|
|
500
|
+
}
|
|
501
|
+
if (typeof input.trailColor === 'string' && TRAIL_COLOR_PATTERN.test(input.trailColor.trim())) {
|
|
502
|
+
out.trailColor = input.trailColor.trim().toUpperCase()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 自建预设:只做**结构化**归一,不做全量校验。
|
|
506
|
+
// 刻意不 import lib/presets.mjs —— 那个模块反过来 import 本模块的枚举常量,
|
|
507
|
+
// 会造成循环引用;且迁移期本就该宽容(坏条目丢弃,不因一条脏数据让整份配置读不出来)。
|
|
508
|
+
// 全量校验由写入路径(POST /api/anima/presets/save → sanitizeCustomPreset)
|
|
509
|
+
// 负责,那里报错才有意义。
|
|
510
|
+
if (Array.isArray(input.presets)) out.presets = normalizePresetsShallow(input.presets)
|
|
511
|
+
|
|
512
|
+
out.memory = { ...base.memory }
|
|
513
|
+
if (isPlainObject(input.memory)) {
|
|
514
|
+
for (const key of ['enabled', 'autoCapture', 'nightlyTriage', 'notifyOnSessionStart', 'retrospectEnabled']) {
|
|
515
|
+
if (typeof input.memory[key] === 'boolean') out.memory[key] = input.memory[key]
|
|
516
|
+
}
|
|
517
|
+
out.memory.nightlyHour = clampInt(input.memory.nightlyHour, 0, 23, base.memory.nightlyHour)
|
|
518
|
+
out.memory.maxPending = clampInt(input.memory.maxPending, 1, 5000, base.memory.maxPending)
|
|
519
|
+
out.memory.maxInjected = clampInt(input.memory.maxInjected, 0, 500, base.memory.maxInjected)
|
|
520
|
+
// 下面两项与上面三项同在 sanitizeConfig 的写入白名单里,读取路径必须一并搬。
|
|
521
|
+
// 漏搬的后果不是「读不出来」而是「静默改回默认值」—— 用户设的字符预算会在
|
|
522
|
+
// 下一次读配置时消失,且因为写入路径认得它,设置页连报错都没有。
|
|
523
|
+
out.memory.maxInjectedChars = clampInt(input.memory.maxInjectedChars, 0, 100000, base.memory.maxInjectedChars)
|
|
524
|
+
out.memory.retrospectDays = clampInt(input.memory.retrospectDays, 1, 365, base.memory.retrospectDays)
|
|
525
|
+
if (MEMORY_LAYERS.includes(input.memory.defaultLayer)) {
|
|
526
|
+
out.memory.defaultLayer = input.memory.defaultLayer
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
out.quality = { ...base.quality }
|
|
531
|
+
if (isPlainObject(input.quality)) {
|
|
532
|
+
for (const key of ['enabled', 'strict', 'preStepCheck']) {
|
|
533
|
+
if (typeof input.quality[key] === 'boolean') out.quality[key] = input.quality[key]
|
|
534
|
+
}
|
|
535
|
+
if (LEDGER_LEVELS.includes(input.quality.ledgerLevel)) {
|
|
536
|
+
out.quality.ledgerLevel = input.quality.ledgerLevel
|
|
537
|
+
}
|
|
538
|
+
out.quality.maxFindingsInReminder = clampInt(
|
|
539
|
+
input.quality.maxFindingsInReminder, 1, 20, base.quality.maxFindingsInReminder
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return out
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 校验工作区覆盖文件的内容。
|
|
548
|
+
* @returns {{overrides: object, errors: Record<string,string>}}
|
|
549
|
+
* 只保留 OVERRIDABLE_FIELDS 中「非空字符串」的字段;
|
|
550
|
+
* 空串表示「本工作区不覆盖该字段」,不写入 overrides。
|
|
551
|
+
*/
|
|
552
|
+
export function sanitizeOverrides(raw) {
|
|
553
|
+
const overrides = {}
|
|
554
|
+
const errors = {}
|
|
555
|
+
if (!isPlainObject(raw)) return { overrides, errors: { _: '覆盖文件必须是一个 JSON 对象' } }
|
|
556
|
+
|
|
557
|
+
// 兼容两种写法:{ overrides: {...} } 或直接 {...}
|
|
558
|
+
const body = isPlainObject(raw.overrides) ? raw.overrides : raw
|
|
559
|
+
|
|
560
|
+
for (const key of OVERRIDABLE_FIELDS) {
|
|
561
|
+
if (FORBIDDEN_KEYS.has(key)) continue
|
|
562
|
+
if (!(key in body)) continue
|
|
563
|
+
const value = body[key]
|
|
564
|
+
|
|
565
|
+
if (typeof value !== 'string') {
|
|
566
|
+
errors[key] = '必须为字符串'
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const text = value.trim()
|
|
570
|
+
if (!text) continue // 空串 = 不覆盖(继承全局)
|
|
571
|
+
|
|
572
|
+
// 旧版覆盖文件里存的是档位键('casual' / 'more'),翻译成文案再落。
|
|
573
|
+
// 不做这一步的话,覆盖文件里的 'casual' 会被当成用户手写的一句英文语气说明
|
|
574
|
+
// 原样注入提示词 —— 用户升级后会看到行为变了但界面显示的是自己没写过的字样。
|
|
575
|
+
//
|
|
576
|
+
// 一个刻意的语义取舍:老文件里写 `headingLists: 'default'`(显式选「默认」档)
|
|
577
|
+
// 翻译后是空串,于是被当成「本工作区不覆盖」而改为继承全局值。这是 v3 的必然结果
|
|
578
|
+
// —— 自由文本模型里没有「显式的不约束」这个状态,空就是继承,与其余文本字段一致。
|
|
579
|
+
const translated = translateWording(key, text)
|
|
580
|
+
const final = translated === null ? text : translated
|
|
581
|
+
if (!final) continue
|
|
582
|
+
|
|
583
|
+
if (final.length > FIELD_LIMITS[key]) {
|
|
584
|
+
errors[key] = `长度超过上限 ${FIELD_LIMITS[key]} 字符(当前 ${final.length})`
|
|
585
|
+
continue
|
|
586
|
+
}
|
|
587
|
+
overrides[key] = final
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return { overrides, errors }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ==================== 两级合并 ====================
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* field-level merge:全局值打底,工作区只覆盖它明确声明的字段。
|
|
597
|
+
* @param {object} globalConfig 已 migrate 的全局配置
|
|
598
|
+
* @param {object|null} overrides 已 sanitize 的工作区覆盖(可为 null)
|
|
599
|
+
* @returns {{effective: object, sources: Record<string,'global'|'workspace'>}}
|
|
600
|
+
*/
|
|
601
|
+
export function mergePersona(globalConfig, overrides) {
|
|
602
|
+
const effective = {}
|
|
603
|
+
const sources = {}
|
|
604
|
+
for (const key of OVERRIDABLE_FIELDS) {
|
|
605
|
+
const local = overrides && typeof overrides[key] === 'string' ? overrides[key] : ''
|
|
606
|
+
if (local) {
|
|
607
|
+
effective[key] = local
|
|
608
|
+
sources[key] = 'workspace'
|
|
609
|
+
} else {
|
|
610
|
+
effective[key] = globalConfig[key] ?? ''
|
|
611
|
+
sources[key] = 'global'
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return { effective, sources }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export { FORBIDDEN_KEYS, isPlainObject }
|