dsh-soul 0.3.3 → 0.5.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/index.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  // index.mjs — dsh-soul host 入口
2
2
  //
3
- // 三层架构:
4
- // - Identity(身份):角色定义、人格特征
5
- // - Behavior(行为):决策逻辑、行为约束
6
- // - Style(风格):语气、表达方式、格式
3
+ // 提示词结构:
4
+ // - 身份(自定义指令 → [角色设定])
5
+ // - 用户背景(「关于你」→ [用户背景])
6
+ // - 行为(风格/特质/输出语言 → [回复风格])
7
+ // - 执行规则([执行规则])
8
+ // 全部固定文案按 config.language 提供中英两套(PROMPT_TEXT 文案表)。
7
9
  //
8
10
  // 功能:
9
11
  // - 支持自定义 Agent 回复风格和语调
@@ -12,6 +14,7 @@
12
14
  // - 提供 HTTP API 供客户端调用
13
15
  // - 注册斜杠命令 /soul 查看当前配置
14
16
  // - 注入系统提示词到 Agent
17
+ // - 配置写入统一走写队列,返回 changed 供调用方跳过无变化刷新
15
18
 
16
19
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
17
20
  import { join } from 'node:path'
@@ -22,8 +25,11 @@ import {
22
25
  STYLE_VALUES,
23
26
  TRAIT_VALUES,
24
27
  LANGUAGE_VALUES,
28
+ PERSONA_FIELDS,
29
+ PERSONA_NAME_MAX,
25
30
  migrateConfig,
26
- sanitizeConfig
31
+ sanitizeConfig,
32
+ sanitizePersonaName
27
33
  } from './lib/config.mjs'
28
34
 
29
35
  const name = 'soul'
@@ -33,132 +39,129 @@ function configPath() {
33
39
  return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'soul-config.json')
34
40
  }
35
41
 
36
- // ==================== 三层架构 ====================
37
-
38
- // Identity 层:身份定义(Agent 自己的角色)
39
- const IdentityLayer = {
40
- // 角色定义
41
- roles: {
42
- 'default': '助手',
43
- 'mentor': '导师',
44
- 'friend': '朋友',
45
- 'expert': '专家',
46
- 'critic': '批评家'
42
+ // ==================== 提示词文案与构建器 ====================
43
+
44
+ // 提示词固定文案表:所有进入 system prompt / 会话注入 / 工具返回的文本,
45
+ // 按输出语言(config.language)提供中英两套,键集完全对齐。
46
+ const PROMPT_TEXT = {
47
+ zh: {
48
+ roleHeader: '[角色设定]',
49
+ profileHeader: '[用户背景]',
50
+ profileDisclaimer: '以下信息描述的是与你对话的用户本人,不是你的身份;不要把用户的职业、技术背景或自述当成你自己的设定。',
51
+ profileNickname: (n) => `- 昵称:${n}(回复时使用这个称呼)`,
52
+ profileOccupation: (o) => `- 职业:${o}`,
53
+ profileBio: (b) => `- 用户自述:${b}`,
54
+ styleHeader: '[回复风格]',
55
+ styleLabel: '回复风格和语调:',
56
+ styles: {
57
+ professional: '专业严谨、注重逻辑和准确性、正式礼貌',
58
+ casual: '轻松自然、像朋友一样交流、中性客观',
59
+ humorous: '幽默风趣、适当使用比喻和玩笑、口语化',
60
+ roast: '吐槽达人、犀利幽默、爱调侃但无恶意、口语化',
61
+ efficient: '高效干练、直击要点、简洁明了、不绕弯子'
62
+ },
63
+ headingLists: {
64
+ more: '采用清晰的格式和列表结构组织回答,善用标题和列表',
65
+ less: '使用更多段落文本,减少标题和列表的使用'
66
+ },
67
+ emoji: {
68
+ more: '在回复中使用较多表情符号',
69
+ less: '尽量减少使用表情符号'
70
+ },
71
+ replyLanguage: {
72
+ zh: '必须使用简体中文回复',
73
+ en: '必须使用英语(English)回复'
74
+ },
75
+ joiner: ',',
76
+ rulesHeader: '[执行规则]',
77
+ rules: '你必须在每一条回复中严格遵守以上角色设定和回复风格;涉及用户背景时,用它来理解用户、贴合用户的需求和水平作答,而不是把它当作你自己的身份。包括但不限于:代码解释、技术问答、日常闲聊、知识科普等所有场景。违反此规则视为失败。',
78
+ injectUpdatedHeader: '[dsh-soul 个性化配置已更新]',
79
+ injectUpdatedBody: [
80
+ '以下是当前最新的个性化配置快照(含昵称、回复风格和语调、特质、输出语言等)。',
81
+ '从现在开始必须按照此配置回复,不要继续使用旧的昵称、角色、风格、语调、特质或输出语言。'
82
+ ].join('\n'),
83
+ injectDisabled: [
84
+ '[dsh-soul 个性化配置已关闭]',
85
+ '从现在开始不要使用 dsh-soul 之前注入的昵称、角色、回复风格、语调、特质或输出语言配置。',
86
+ '请恢复使用 Agent 的默认行为。'
87
+ ].join('\n'),
88
+ toolUpdated: '已更新个性化设置',
89
+ toolNoChanges: '没有要更改的设置',
90
+ toolValidationFailed: '配置校验失败:',
91
+ toolProposal: '已收到人设变更请求(确认模式已开启,尚未生效):',
92
+ toolProposalHint: '在用户确认前不会生效;用户可用 /soul confirm 确认,或 /soul reject 拒绝。'
47
93
  },
48
-
49
- // 构建身份描述
50
- build(config) {
51
- const parts = []
52
-
53
- // 用户自定义角色
54
- if (config.customInstructions) {
55
- parts.push(config.customInstructions)
56
- }
57
-
58
- return parts.join('\n')
94
+ en: {
95
+ roleHeader: '[Role]',
96
+ profileHeader: '[User profile]',
97
+ profileDisclaimer: 'The information below describes the user you are talking to — it is NOT your own identity; do not treat the user\'s occupation, background or self-description as your persona.',
98
+ profileNickname: (n) => `- Nickname: ${n} (address the user by this name in replies)`,
99
+ profileOccupation: (o) => `- Occupation: ${o}`,
100
+ profileBio: (b) => `- Self-description: ${b}`,
101
+ styleHeader: '[Reply style]',
102
+ styleLabel: 'Reply style & tone: ',
103
+ styles: {
104
+ professional: 'Rigorous and professional, focused on logic and accuracy, formal and courteous',
105
+ casual: 'Relaxed and natural, chatting like a friend, neutral and objective',
106
+ humorous: 'Humorous, apt with analogies and jokes, colloquial',
107
+ roast: 'Playful roaster, sharp and witty but never mean, colloquial',
108
+ efficient: 'Efficient and straight to the point, concise, no beating around the bush'
109
+ },
110
+ headingLists: {
111
+ more: 'Organize answers with clear formatting, headings and lists',
112
+ less: 'Use more paragraph text; minimize headings and lists'
113
+ },
114
+ emoji: {
115
+ more: 'Use emojis fairly often in replies',
116
+ less: 'Keep emoji usage to a minimum'
117
+ },
118
+ replyLanguage: {
119
+ zh: '必须使用简体中文回复',
120
+ en: 'You must reply in English.'
121
+ },
122
+ joiner: '; ',
123
+ rulesHeader: '[Execution rules]',
124
+ rules: 'You must strictly follow the role setup and reply style above in EVERY reply; when user background is involved, use it to understand the user and tailor your answers to their needs and level — never treat it as your own identity. This covers all scenarios including code explanation, technical Q&A, casual chat and knowledge sharing. Violating this rule counts as failure.',
125
+ injectUpdatedHeader: '[dsh-soul] Personalization config updated',
126
+ injectUpdatedBody: [
127
+ 'Below is the latest personalization snapshot (nickname, reply style & tone, traits, output language, etc.).',
128
+ 'From now on you must reply according to this config; do not keep using the previous nickname, role, style, tone, traits or output language.'
129
+ ].join('\n'),
130
+ injectDisabled: [
131
+ '[dsh-soul] Personalization config disabled',
132
+ 'Stop using any nickname, role, reply style, tone, traits or output language previously injected by dsh-soul.',
133
+ 'Return to your default behavior.'
134
+ ].join('\n'),
135
+ toolUpdated: 'Personalization settings updated',
136
+ toolNoChanges: 'No settings to change',
137
+ toolValidationFailed: 'Config validation failed: ',
138
+ toolProposal: 'Persona change requested (confirmation mode is on — not applied yet):',
139
+ toolProposalHint: 'It stays pending until the user confirms with /soul confirm or rejects with /soul reject.'
59
140
  }
60
141
  }
61
142
 
62
- // 用户背景层:关于对话用户的信息(不属于 Agent 角色设定)
63
- function buildUserProfile(config) {
64
- const lines = []
65
- if (config.nickname) {
66
- lines.push(`- 昵称:${config.nickname}(回复时使用这个称呼)`)
67
- }
68
- if (config.occupation) {
69
- lines.push(`- 职业:${config.occupation}`)
70
- }
71
- if (config.bio) {
72
- lines.push(`- 用户自述:${config.bio}`)
73
- }
74
- return lines.join('\n')
143
+ // 按配置选择文案表(未知语言回退中文)
144
+ function promptTextOf(config) {
145
+ return PROMPT_TEXT[config && config.language === 'en' ? 'en' : 'zh']
75
146
  }
76
147
 
77
- // Behavior 层:行为规则
78
- const BehaviorLayer = {
79
- // 回复风格和语调(v0.2.0 起合并为单一选项)
80
- styles: {
81
- professional: '专业严谨、注重逻辑和准确性、正式礼貌',
82
- casual: '轻松自然、像朋友一样交流、中性客观',
83
- humorous: '幽默风趣、适当使用比喻和玩笑、口语化',
84
- roast: '吐槽达人、犀利幽默、爱调侃但无恶意、口语化',
85
- efficient: '高效干练、直击要点、简洁明了、不绕弯子'
86
- },
87
-
88
- // 行为规则
89
- rules: [
90
- '在回答任何问题时,都必须体现上述身份特征',
91
- '禁止使用标准AI助手的中性语气',
92
- '保持角色一致性,不要跳出设定'
93
- ],
94
-
95
- // 回复语言指令(同时决定 /soul 命令输出语言)
96
- languages: {
97
- zh: '必须使用简体中文回复',
98
- en: '必须使用英语(English)回复'
99
- },
100
-
101
- // 特质:标题和列表的使用程度
102
- headingLists: {
103
- more: '采用清晰的格式和列表结构组织回答,善用标题和列表',
104
- less: '使用更多段落文本,减少标题和列表的使用'
105
- },
106
-
107
- // 特质:表情符号的使用程度
108
- emoji: {
109
- more: '在回复中使用较多表情符号',
110
- less: '尽量减少使用表情符号'
111
- },
112
-
113
- // 构建行为描述
114
- build(config) {
115
- const parts = []
116
-
117
- // 回复风格和语调
118
- if (config.style && this.styles[config.style]) {
119
- parts.push(`回复风格和语调:${this.styles[config.style]}`)
120
- }
121
-
122
- // 特质:标题和列表
123
- if (config.headingLists && this.headingLists[config.headingLists]) {
124
- parts.push(this.headingLists[config.headingLists])
125
- }
126
-
127
- // 特质:表情符号
128
- if (config.emoji && this.emoji[config.emoji]) {
129
- parts.push(this.emoji[config.emoji])
130
- }
131
-
132
- // 回复语言
133
- if (config.language && this.languages[config.language]) {
134
- parts.push(this.languages[config.language])
135
- }
136
-
137
- return parts.join(',')
138
- }
148
+ // 用户背景块(「关于你」):描述对话用户本人,不属于 Agent 的角色设定
149
+ function buildUserProfile(config, T) {
150
+ const lines = []
151
+ if (config.nickname) lines.push(T.profileNickname(config.nickname))
152
+ if (config.occupation) lines.push(T.profileOccupation(config.occupation))
153
+ if (config.bio) lines.push(T.profileBio(config.bio))
154
+ return lines.join('\n')
139
155
  }
140
156
 
141
- // Style 层:输出格式
142
- const StyleLayer = {
143
- // 格式模板
144
- templates: {
145
- default: '{content}',
146
- structured: '## {title}\n\n{content}',
147
- casual: '{content}~',
148
- formal: '尊敬的{nickname},{content}'
149
- },
150
-
151
- // 构建风格描述
152
- build(config) {
153
- const parts = []
154
-
155
- // 根据风格选择格式
156
- if (config.style === 'casual') {
157
- parts.push('使用轻松的语气')
158
- }
159
-
160
- return parts.join(',')
161
- }
157
+ // 行为块:回复风格和语调 + 特质 + 输出语言
158
+ function buildBehavior(config, T) {
159
+ const parts = []
160
+ if (config.style && T.styles[config.style]) parts.push(`${T.styleLabel}${T.styles[config.style]}`)
161
+ if (config.headingLists && T.headingLists[config.headingLists]) parts.push(T.headingLists[config.headingLists])
162
+ if (config.emoji && T.emoji[config.emoji]) parts.push(T.emoji[config.emoji])
163
+ if (config.language && T.replyLanguage[config.language]) parts.push(T.replyLanguage[config.language])
164
+ return parts.join(T.joiner)
162
165
  }
163
166
 
164
167
  // ==================== 配置管理 ====================
@@ -215,13 +218,36 @@ function enqueueConfigWrite(task) {
215
218
 
216
219
  // 在写队列中执行一次配置更新:mutate 收到当前配置的浅拷贝,返回新配置对象。
217
220
  // 注意 mutate 必须在队列任务内完成全部读取与合并,不要在队列外提前读取配置。
221
+ // 返回 { config, changed }:changed 为实际发生变化的字段名数组,
222
+ // 调用方在其为空时应跳过提示词刷新与会话注入(配置没有变化)。
218
223
  async function commitConfig(mutate) {
219
224
  return enqueueConfigWrite(async () => {
220
- const next = mutate({ ...(await loadConfig()) })
221
- return await saveConfig(next)
225
+ const current = await loadConfig()
226
+ const next = mutate({ ...current })
227
+ const changed = diffKeys(current, next)
228
+ const config = await saveConfig(next)
229
+ return { config, changed }
222
230
  })
223
231
  }
224
232
 
233
+ // 比较两份配置的差异字段名(浅比较;soul 配置为扁平对象)。
234
+ // personas(人设预设库)不属于活动配置:库的增删改不应触发提示词刷新与会话注入。
235
+ function diffKeys(a, b) {
236
+ const changed = []
237
+ for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) {
238
+ if (key === 'personas') continue
239
+ if (a[key] !== b[key]) changed.push(key)
240
+ }
241
+ return changed
242
+ }
243
+
244
+ // 重置为默认配置,但保留人设预设库(预设属于用户资产,重置不应清空)
245
+ function defaultConfigPreservingPersonas(current) {
246
+ const next = { ...DEFAULT_CONFIG }
247
+ if (current && current.personas) next.personas = current.personas
248
+ return next
249
+ }
250
+
225
251
  // 将字段级校验错误汇总为一行文本(HTTP 错误信息 / 工具返回消息共用)
226
252
  function formatFieldErrors(errors) {
227
253
  return Object.entries(errors)
@@ -229,48 +255,70 @@ function formatFieldErrors(errors) {
229
255
  .join(';')
230
256
  }
231
257
 
258
+ // 读取 JSON 请求体:限制大小(超限读完丢弃并返回 413),解析失败返回 400。
259
+ // 超限时继续读完剩余数据,避免连接中残留未读字节污染 keep-alive。
260
+ async function readJsonBody(req) {
261
+ const chunks = []
262
+ let size = 0
263
+ let oversized = false
264
+ for await (const chunk of req) {
265
+ size += chunk.length
266
+ if (size > MAX_BODY_BYTES) {
267
+ oversized = true
268
+ chunks.length = 0
269
+ continue
270
+ }
271
+ chunks.push(chunk)
272
+ }
273
+ if (oversized) {
274
+ return { ok: false, status: 413, error: `请求体超过大小上限 ${MAX_BODY_BYTES} 字节` }
275
+ }
276
+ try {
277
+ return { ok: true, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
278
+ } catch (err) {
279
+ return { ok: false, status: 400, error: '请求体不是合法的 JSON:' + String(err.message || err) }
280
+ }
281
+ }
282
+
232
283
  // ==================== 提示词编译器 ====================
233
284
 
234
- // Prompt Compiler:编译三层架构为系统提示词
285
+ // Prompt Compiler:将身份(自定义指令)、用户背景与行为(风格/特质/语言)
286
+ // 按 config.language 对应的文案表编译为 system prompt。
235
287
  function compilePrompt(config) {
236
288
  if (!config.enabled) {
237
289
  return ''
238
290
  }
239
-
240
- const identity = IdentityLayer.build(config)
241
- const behavior = BehaviorLayer.build(config)
242
- const style = StyleLayer.build(config)
243
- const profile = buildUserProfile(config)
244
-
291
+
292
+ const T = promptTextOf(config)
293
+ const identity = (config.customInstructions || '').trim()
294
+ const profile = buildUserProfile(config, T)
295
+ const behavior = buildBehavior(config, T)
296
+
245
297
  // 如果没有任何配置,返回空
246
298
  if (!identity && !behavior && !profile) {
247
299
  return ''
248
300
  }
249
-
250
- // 编译提示词
301
+
251
302
  const parts = []
252
-
303
+
253
304
  // 身份层
254
305
  if (identity) {
255
- parts.push(`[角色设定] ${identity}`)
306
+ parts.push(`${T.roleHeader} ${identity}`)
256
307
  }
257
-
258
- // 用户背景:明确声明描述的是用户本人,防止模型把用户信息当成自己的身份
308
+
309
+ // 用户背景块:明确声明描述的是用户本人,防止模型把用户信息当成自己的身份
259
310
  if (profile) {
260
- parts.push([
261
- '[用户背景] 以下信息描述的是与你对话的用户本人,不是你的身份;不要把用户的职业、技术背景或自述当成你自己的设定。',
262
- profile
263
- ].join('\n'))
311
+ parts.push(`${T.profileHeader} ${T.profileDisclaimer}\n${profile}`)
264
312
  }
265
-
313
+
266
314
  // 行为层
267
315
  if (behavior) {
268
- parts.push(`[回复风格] ${behavior}`)
316
+ parts.push(`${T.styleHeader} ${behavior}`)
269
317
  }
270
-
318
+
271
319
  // 执行规则
272
- parts.push(`[执行规则] 你必须在每一条回复中严格遵守以上角色设定和回复风格;涉及用户背景时,用它来理解用户、贴合用户的需求和水平作答,而不是把它当作你自己的身份。包括但不限于:代码解释、技术问答、日常闲聊、知识科普等所有场景。违反此规则视为失败。`)
273
-
320
+ parts.push(`${T.rulesHeader} ${T.rules}`)
321
+
274
322
  return parts.join('\n')
275
323
  }
276
324
 
@@ -285,26 +333,16 @@ function injectPromptToAllAgents(ctx, config) {
285
333
  return
286
334
  }
287
335
 
336
+ const T = promptTextOf(config)
288
337
  const prompt = compilePrompt(config)
289
- const disabledText = [
290
- '[dsh-soul 个性化配置已关闭]',
291
- '从现在开始不要使用 dsh-soul 之前注入的昵称、角色、回复风格、语调、特质或输出语言配置。',
292
- '请恢复使用 Agent 的默认行为。'
293
- ].join('\n')
294
- const snapshotText = prompt || disabledText
338
+ const snapshotText = prompt || T.injectDisabled
295
339
 
296
340
  for (const agent of agents.list()) {
297
341
  try {
298
342
  agent.inject(createUserMessage({
299
343
  content: [{
300
344
  type: 'text',
301
- text: prompt ? [
302
- '[dsh-soul 个性化配置已更新]',
303
- '以下是当前最新的个性化配置快照(含昵称、回复风格和语调、特质、输出语言等)。',
304
- '从现在开始必须按照此配置回复,不要继续使用旧的昵称、角色、风格、语调、特质或输出语言。',
305
- '',
306
- prompt
307
- ].join('\n') : snapshotText
345
+ text: prompt ? `${T.injectUpdatedHeader}\n${T.injectUpdatedBody}\n\n${prompt}` : snapshotText
308
346
  }],
309
347
  source: {
310
348
  kind: 'plugin',
@@ -351,48 +389,28 @@ function registerRoutes(ctx) {
351
389
  const config = await loadConfig()
352
390
  send(200, { ok: true, config })
353
391
  } else if (req.method === 'POST') {
354
- // 读取请求体:超过大小上限则丢弃内容但继续读完剩余数据,
355
- // 避免连接中残留未读字节污染 keep-alive
356
- const chunks = []
357
- let size = 0
358
- let oversized = false
359
- for await (const chunk of req) {
360
- size += chunk.length
361
- if (size > MAX_BODY_BYTES) {
362
- oversized = true
363
- chunks.length = 0
364
- continue
365
- }
366
- chunks.push(chunk)
367
- }
368
- if (oversized) {
369
- send(413, { ok: false, error: `请求体超过大小上限 ${MAX_BODY_BYTES} 字节` })
370
- return
371
- }
372
-
373
- // JSON 解析失败属于客户端错误:返回 400 而非 500
374
- let body
375
- try {
376
- body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
377
- } catch (err) {
378
- send(400, { ok: false, error: '请求体不是合法的 JSON:' + String(err.message || err) })
392
+ const parsed = await readJsonBody(req)
393
+ if (!parsed.ok) {
394
+ send(parsed.status, { ok: false, error: parsed.error })
379
395
  return
380
396
  }
381
397
 
382
398
  // 输入校验:字段白名单 + 类型 + 长度上限 + 枚举;存在错误时整单拒绝
383
- const { patch, errors } = sanitizeConfig(body)
399
+ const { patch, errors } = sanitizeConfig(parsed.body)
384
400
  if (Object.keys(errors).length > 0) {
385
401
  send(400, { ok: false, error: '配置校验失败:' + formatFieldErrors(errors), errors })
386
402
  return
387
403
  }
388
404
 
389
405
  // 写队列内「读—改—写」,只合并通过校验的字段(未知字段不落盘)
390
- const updated = await commitConfig(current => ({ ...current, ...patch }))
391
-
392
- // 配置变化后更新系统提示词,并注入所有活动会话
393
- refreshPromptAndInject(ctx, updated)
394
-
395
- send(200, { ok: true, config: updated })
406
+ const { config: updated, changed } = await commitConfig(current => ({ ...current, ...patch }))
407
+
408
+ // 配置有实际变化才更新系统提示词并注入会话,避免无变化保存堆积注入消息
409
+ if (changed.length > 0) {
410
+ refreshPromptAndInject(ctx, updated)
411
+ }
412
+
413
+ send(200, { ok: true, config: updated, changed })
396
414
  } else {
397
415
  send(405, { ok: false, error: 'Method not allowed' })
398
416
  }
@@ -435,19 +453,192 @@ function registerRoutes(ctx) {
435
453
  }
436
454
 
437
455
  try {
438
- const config = await commitConfig(() => ({ ...DEFAULT_CONFIG }))
456
+ const { config, changed } = await commitConfig(current => defaultConfigPreservingPersonas(current))
439
457
 
440
- // 配置变化后更新系统提示词,并注入所有活动会话
441
- refreshPromptAndInject(ctx, config)
458
+ // 配置有实际变化才更新系统提示词并注入会话
459
+ if (changed.length > 0) {
460
+ refreshPromptAndInject(ctx, config)
461
+ }
442
462
 
443
- send(200, { ok: true, config })
463
+ send(200, { ok: true, config, changed })
444
464
  } catch (err) {
445
465
  send(500, { ok: false, error: String(err.message || err) })
446
466
  }
447
467
  }
448
468
  })
449
469
 
450
- // 预设管理端点已在 v0.2.0 移除(人设预设功能下线)
470
+ // 人设预设管理端点见 registerPersonaRoutes(v0.5.0 重新提供该能力)
471
+ })
472
+ }
473
+
474
+ // ==================== 人设预设 ====================
475
+
476
+ // 待确认的人设变更提议(set_persona 确认模式;进程内短生命周期,不做持久化)
477
+ let pendingPersonaProposal = null
478
+
479
+ // 从配置中提取人设字段快照(作为预设保存的内容)
480
+ function personaSnapshotOf(config) {
481
+ const values = {}
482
+ for (const key of PERSONA_FIELDS) values[key] = config[key]
483
+ return values
484
+ }
485
+
486
+ // 从预设条目中挑出人设字段(忽略 updatedAt 等元数据)
487
+ function pickPersonaValues(persona) {
488
+ const values = {}
489
+ for (const key of PERSONA_FIELDS) {
490
+ if (key in persona) values[key] = persona[key]
491
+ }
492
+ return values
493
+ }
494
+
495
+ // 浅拷贝人设库(仅自有可枚举键,规避原型污染键)
496
+ function clonePersonas(personas) {
497
+ const out = {}
498
+ for (const key of Object.keys(personas || {})) out[key] = { ...personas[key] }
499
+ return out
500
+ }
501
+
502
+ function hasOwn(obj, key) {
503
+ return Object.prototype.hasOwnProperty.call(obj, key)
504
+ }
505
+
506
+ // 计算与当前活动配置完全一致的预设名(多个命中时取名称排序最前)
507
+ function findActivePersonaName(config) {
508
+ const personas = config.personas || {}
509
+ for (const personaName of Object.keys(personas).sort()) {
510
+ const entry = personas[personaName]
511
+ if (PERSONA_FIELDS.every((key) => entry[key] === config[key])) return personaName
512
+ }
513
+ return null
514
+ }
515
+
516
+ // 人设预设 HTTP 路由:列表 / 保存 / 使用 / 删除。
517
+ // 保存与删除仅变更 personas 库(diffKeys 跳过该字段),不会触发会话注入;
518
+ // 使用会变更活动配置字段,走常规 changed 门控注入。
519
+ function registerPersonaRoutes(ctx) {
520
+ ctx.inject(['webServer'], (wsCtx) => {
521
+ const send = (res, status, body) => {
522
+ res.writeHead(status, { 'content-type': 'application/json' })
523
+ res.end(JSON.stringify(body))
524
+ }
525
+
526
+ // 列表
527
+ wsCtx.webServer.register({
528
+ kind: 'exact',
529
+ path: '/api/soul/personas',
530
+ handler: async (req, res) => {
531
+ try {
532
+ if (req.method !== 'GET') {
533
+ send(res, 405, { ok: false, error: 'Method not allowed' })
534
+ return
535
+ }
536
+ const config = await loadConfig()
537
+ send(res, 200, { ok: true, personas: config.personas || {}, activeName: findActivePersonaName(config) })
538
+ } catch (err) {
539
+ send(res, 500, { ok: false, error: String(err.message || err) })
540
+ }
541
+ }
542
+ })
543
+
544
+ // 保存当前配置为预设(upsert)
545
+ wsCtx.webServer.register({
546
+ kind: 'exact',
547
+ path: '/api/soul/personas/save',
548
+ handler: async (req, res) => {
549
+ try {
550
+ if (req.method !== 'POST') {
551
+ send(res, 405, { ok: false, error: 'Method not allowed' })
552
+ return
553
+ }
554
+ const parsed = await readJsonBody(req)
555
+ if (!parsed.ok) {
556
+ send(res, parsed.status, { ok: false, error: parsed.error })
557
+ return
558
+ }
559
+ const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
560
+ if (!personaName) {
561
+ send(res, 400, { ok: false, error: `预设名称无效(1-${PERSONA_NAME_MAX} 个字符)` })
562
+ return
563
+ }
564
+ const { config: updated } = await commitConfig((current) => {
565
+ const personas = clonePersonas(current.personas)
566
+ personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
567
+ return { ...current, personas }
568
+ })
569
+ send(res, 200, { ok: true, personas: updated.personas || {}, activeName: findActivePersonaName(updated) })
570
+ } catch (err) {
571
+ send(res, 500, { ok: false, error: String(err.message || err) })
572
+ }
573
+ }
574
+ })
575
+
576
+ // 使用预设:将预设字段应用到活动配置(走 changed 门控注入)
577
+ wsCtx.webServer.register({
578
+ kind: 'exact',
579
+ path: '/api/soul/personas/use',
580
+ handler: async (req, res) => {
581
+ try {
582
+ if (req.method !== 'POST') {
583
+ send(res, 405, { ok: false, error: 'Method not allowed' })
584
+ return
585
+ }
586
+ const parsed = await readJsonBody(req)
587
+ if (!parsed.ok) {
588
+ send(res, parsed.status, { ok: false, error: parsed.error })
589
+ return
590
+ }
591
+ const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
592
+ const current = await loadConfig()
593
+ if (!personaName || !hasOwn(current.personas || {}, personaName)) {
594
+ send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
595
+ return
596
+ }
597
+ // 预设值保存时已校验;此处再过一遍白名单,防御手改文件等异常数据
598
+ const { patch } = sanitizeConfig(pickPersonaValues(current.personas[personaName]))
599
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, ...patch }))
600
+ if (changed.length > 0) {
601
+ refreshPromptAndInject(ctx, updated)
602
+ }
603
+ send(res, 200, { ok: true, config: updated, changed, unchanged: changed.length === 0 })
604
+ } catch (err) {
605
+ send(res, 500, { ok: false, error: String(err.message || err) })
606
+ }
607
+ }
608
+ })
609
+
610
+ // 删除预设
611
+ wsCtx.webServer.register({
612
+ kind: 'exact',
613
+ path: '/api/soul/personas/delete',
614
+ handler: async (req, res) => {
615
+ try {
616
+ if (req.method !== 'POST') {
617
+ send(res, 405, { ok: false, error: 'Method not allowed' })
618
+ return
619
+ }
620
+ const parsed = await readJsonBody(req)
621
+ if (!parsed.ok) {
622
+ send(res, parsed.status, { ok: false, error: parsed.error })
623
+ return
624
+ }
625
+ const personaName = sanitizePersonaName(parsed.body && parsed.body.name)
626
+ const current = await loadConfig()
627
+ if (!personaName || !hasOwn(current.personas || {}, personaName)) {
628
+ send(res, 400, { ok: false, error: `预设不存在:${personaName || '(空)'}` })
629
+ return
630
+ }
631
+ const { config: updated } = await commitConfig((c) => {
632
+ const personas = clonePersonas(c.personas)
633
+ delete personas[personaName]
634
+ return { ...c, personas }
635
+ })
636
+ send(res, 200, { ok: true, personas: updated.personas || {}, activeName: findActivePersonaName(updated) })
637
+ } catch (err) {
638
+ send(res, 500, { ok: false, error: String(err.message || err) })
639
+ }
640
+ }
641
+ })
451
642
  })
452
643
  }
453
644
 
@@ -483,7 +674,30 @@ const COMMAND_MESSAGES = {
483
674
  enabled: '已启用',
484
675
  disabled: '已禁用',
485
676
  notSet: '未设置',
486
- help: '使用 /soul show 查看详情\n使用 /soul reset 重置配置\n使用 /soul enable 启用\n使用 /soul disable 禁用\n使用 /soul 昵称xxx 设置昵称',
677
+ toolConfirmLabel: '确认模式',
678
+ onLabel: '开启',
679
+ offLabel: '关闭',
680
+ personasLabel: '人设预设',
681
+ pendingHint: '⚠ 有待确认的人设变更:/soul confirm 确认,/soul reject 拒绝',
682
+ noPending: '没有待确认的人设变更',
683
+ confirmDone: (detail) => `✅ 已应用人设变更:${detail}`,
684
+ rejectDone: '✅ 已拒绝待确认的人设变更',
685
+ saveDone: (name) => `✅ 已保存预设「${name}」`,
686
+ useDone: (name) => `✅ 已应用预设「${name}」`,
687
+ useUnchanged: (name) => `ℹ 预设「${name}」与当前配置一致,无需变更`,
688
+ personaMissing: (name) => `预设「${name}」不存在`,
689
+ delDone: (name) => `✅ 已删除预设「${name}」`,
690
+ listTitle: '📋 人设预设',
691
+ listEmpty: '暂无人设预设,使用 /soul save <名称> 保存当前配置',
692
+ saveUsage: '用法:/soul save <名称>(1-30 个字符)',
693
+ useUsage: '用法:/soul use <名称>',
694
+ delUsage: '用法:/soul del <名称>',
695
+ setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / language / nickname / occupation / bio / customInstructions)',
696
+ unknownField: '未知配置项',
697
+ invalidEnabled: 'enabled 取值必须为 true / false',
698
+ setNoChanges: '配置无变化,未做修改',
699
+ setDone: (keys) => `✅ 已更新:${keys.join('、')}`,
700
+ help: '使用 /soul show 查看详情\n使用 /soul set style=humorous 修改配置项\n使用 /soul save <名称> 保存预设;/soul use <名称> 应用;/soul list 查看;/soul del <名称> 删除\n使用 /soul confirm 或 /soul reject 处理待确认的人设变更\n使用 /soul reset 重置配置\n使用 /soul enable 启用;/soul disable 禁用\n使用 /soul 昵称xxx 设置昵称',
487
701
  resetDone: '✅ 配置已重置为默认值',
488
702
  enableDone: '✅ 个性化设置已启用',
489
703
  disableDone: '✅ 个性化设置已禁用',
@@ -517,7 +731,30 @@ const COMMAND_MESSAGES = {
517
731
  enabled: 'enabled',
518
732
  disabled: 'disabled',
519
733
  notSet: 'not set',
520
- help: 'Use /soul show to view details\nUse /soul reset to reset\nUse /soul enable to enable\nUse /soul disable to disable\nUse /soul <nickname> to set your nickname',
734
+ toolConfirmLabel: 'Tool confirmation',
735
+ onLabel: 'on',
736
+ offLabel: 'off',
737
+ personasLabel: 'Personas',
738
+ pendingHint: '⚠ Pending persona proposal: /soul confirm to apply, /soul reject to discard',
739
+ noPending: 'No pending persona proposal',
740
+ confirmDone: (detail) => `✅ Persona changes applied: ${detail}`,
741
+ rejectDone: '✅ Pending persona proposal discarded',
742
+ saveDone: (name) => `✅ Persona "${name}" saved`,
743
+ useDone: (name) => `✅ Persona "${name}" applied`,
744
+ useUnchanged: (name) => `ℹ Persona "${name}" matches the current config`,
745
+ personaMissing: (name) => `Persona "${name}" does not exist`,
746
+ delDone: (name) => `✅ Persona "${name}" deleted`,
747
+ listTitle: '📋 Personas',
748
+ listEmpty: 'No personas yet — save the current config with /soul save <name>',
749
+ saveUsage: 'Usage: /soul save <name> (1-30 chars)',
750
+ useUsage: 'Usage: /soul use <name>',
751
+ delUsage: 'Usage: /soul del <name>',
752
+ setUsage: 'Usage: /soul set key=value ... (fields: enabled / style / headingLists / emoji / language / nickname / occupation / bio / customInstructions)',
753
+ unknownField: 'Unknown field',
754
+ invalidEnabled: 'enabled must be true or false',
755
+ setNoChanges: 'No config changes to apply',
756
+ setDone: (keys) => `✅ Updated: ${keys.join(', ')}`,
757
+ help: 'Use /soul show for details\nUse /soul set style=humorous to change fields\nUse /soul save <name> / use <name> / list / del <name> for personas\nUse /soul confirm or /soul reject for pending persona proposals\nUse /soul reset to reset\nUse /soul enable / disable to toggle\nUse /soul <nickname> to set your nickname',
521
758
  resetDone: '✅ Configuration reset to defaults',
522
759
  enableDone: '✅ Personalization enabled',
523
760
  disableDone: '✅ Personalization disabled',
@@ -530,22 +767,52 @@ function commandMessages(config) {
530
767
  return COMMAND_MESSAGES[config.language === 'en' ? 'en' : 'zh']
531
768
  }
532
769
 
770
+ // /soul 子命令关键字(命中走子命令;否则整体视为昵称)
771
+ const COMMAND_KEYWORDS = new Set(['show', 'reset', 'enable', 'disable', 'save', 'use', 'list', 'del', 'delete', 'rm', 'set', 'confirm', 'reject'])
772
+
773
+ // 解析 /soul set 的键值参数:key=value 对;不含 = 的 token 追加到上一个值
774
+ // (支持含空格的文本值,如:/soul set bio=写代码 多年 经验)
775
+ function parseSetArgs(rest) {
776
+ if (!rest) return []
777
+ const pairs = []
778
+ for (const token of rest.split(/\s+/)) {
779
+ const eq = token.indexOf('=')
780
+ if (eq > 0) pairs.push([token.slice(0, eq), token.slice(eq + 1)])
781
+ else if (pairs.length > 0 && token) pairs[pairs.length - 1][1] += ` ${token}`
782
+ else return null
783
+ }
784
+ return pairs
785
+ }
786
+
533
787
  function registerCommands(ctx) {
534
788
  ctx.inject(['commands'], (cmdCtx) => {
535
789
  cmdCtx.commands.register({
536
790
  name: 'soul',
537
- description: '查看或管理个性化设置。用法:/soul [show|reset|enable|disable|昵称]',
538
- input: { hint: '[show|reset|enable|disable|昵称]' },
791
+ description: '查看或管理个性化设置。用法:/soul [show|set|save|use|list|del|confirm|reject|reset|enable|disable|昵称]',
792
+ input: { hint: '[show|set k=v|save <名>|use <名>|list|del <名>|confirm|reject|reset|enable|disable|昵称]' },
539
793
  async handler(invocation) {
540
- // 保留原始大小写用于昵称,仅关键字匹配时忽略大小写
794
+ // 保留原始大小写用于昵称/预设名,仅关键字匹配时忽略大小写
541
795
  const raw = String(invocation.rawInput || '').trim()
542
- const args = raw.toLowerCase()
796
+ const spaceAt = raw.search(/\s/)
797
+ const first = (spaceAt === -1 ? raw : raw.slice(0, spaceAt)).toLowerCase()
798
+ const rest = spaceAt === -1 ? '' : raw.slice(spaceAt + 1).trim()
543
799
 
544
800
  try {
545
801
  const config = await loadConfig()
546
802
  const t = commandMessages(config)
547
803
 
548
- if (args === 'show' || args === '') {
804
+ // 非关键字输入:整体视为昵称(保留原始大小写);空输入走 show
805
+ if (first !== '' && !COMMAND_KEYWORDS.has(first)) {
806
+ const { patch, errors } = sanitizeConfig({ nickname: raw })
807
+ if (errors.nickname) {
808
+ return { kind: 'error', text: t.failed(errors.nickname) }
809
+ }
810
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, nickname: patch.nickname }))
811
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
812
+ return { kind: 'success', text: t.nicknameDone(patch.nickname) }
813
+ }
814
+
815
+ if (first === '' || first === 'show') {
549
816
  const status = config.enabled ? t.enabled : t.disabled
550
817
  const nickname = config.nickname || t.notSet
551
818
  const occupation = config.occupation || t.notSet
@@ -554,38 +821,138 @@ function registerCommands(ctx) {
554
821
  const hlName = t.traitNames[config.headingLists] || config.headingLists
555
822
  const emojiName = t.traitNames[config.emoji] || config.emoji
556
823
  const instructions = config.customInstructions || t.notSet
824
+ const personaCount = Object.keys(config.personas || {}).length
825
+ const pendingLine = pendingPersonaProposal ? `\n${t.pendingHint}` : ''
557
826
 
558
827
  return {
559
828
  kind: 'success',
560
- text: `${t.showTitle}\n\n${t.statusLabel}${t.colon}${status}\n${t.nicknameLabel}${t.colon}${nickname}\n${t.occupationLabel}${t.colon}${occupation}\n${t.bioLabel}${t.colon}${bio}\n${t.styleLabel}${t.colon}${styleName}\n${t.traitsLabel}${t.colon}${t.headingListsLabel}=${hlName},${t.emojiLabel}=${emojiName}\n${t.instructionsLabel}${t.colon}${instructions}\n\n${t.help}`
829
+ text: `${t.showTitle}\n\n${t.statusLabel}${t.colon}${status}\n${t.nicknameLabel}${t.colon}${nickname}\n${t.occupationLabel}${t.colon}${occupation}\n${t.bioLabel}${t.colon}${bio}\n${t.styleLabel}${t.colon}${styleName}\n${t.traitsLabel}${t.colon}${t.headingListsLabel}=${hlName},${t.emojiLabel}=${emojiName}\n${t.instructionsLabel}${t.colon}${instructions}\n${t.toolConfirmLabel}${t.colon}${config.requireToolConfirmation ? t.onLabel : t.offLabel}\n${t.personasLabel}${t.colon}${personaCount}${pendingLine}\n\n${t.help}`
561
830
  }
562
- } else if (args === 'reset') {
563
- const updated = await commitConfig(() => ({ ...DEFAULT_CONFIG }))
564
- // 更新系统提示词,并注入所有活动会话
565
- refreshPromptAndInject(ctx, updated)
831
+ }
832
+
833
+ if (first === 'reset') {
834
+ // 重置活动配置,但保留人设预设库
835
+ const { config: updated, changed } = await commitConfig(current => defaultConfigPreservingPersonas(current))
836
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
566
837
  return { kind: 'success', text: t.resetDone }
567
- } else if (args === 'enable') {
568
- // 写队列内「读—改—写」,不在队列外改写内存缓存对象
569
- const updated = await commitConfig(c => ({ ...c, enabled: true }))
570
- // 更新系统提示词,并注入所有活动会话
571
- refreshPromptAndInject(ctx, updated)
838
+ }
839
+
840
+ if (first === 'enable') {
841
+ // 写队列内「读—改—写」,不在队列外改写内存缓存对象;无变化时跳过注入
842
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, enabled: true }))
843
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
572
844
  return { kind: 'success', text: t.enableDone }
573
- } else if (args === 'disable') {
574
- const updated = await commitConfig(c => ({ ...c, enabled: false }))
575
- // 更新系统提示词,并注入所有活动会话
576
- refreshPromptAndInject(ctx, updated)
845
+ }
846
+
847
+ if (first === 'disable') {
848
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, enabled: false }))
849
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
577
850
  return { kind: 'success', text: t.disableDone }
578
- } else {
579
- // 设置昵称(保留原始大小写,不做 toLowerCase);与 HTTP 保存共用同一套校验
580
- const { patch, errors } = sanitizeConfig({ nickname: raw })
581
- if (errors.nickname) {
582
- return { kind: 'error', text: t.failed(errors.nickname) }
851
+ }
852
+
853
+ if (first === 'save') {
854
+ const personaName = sanitizePersonaName(rest)
855
+ if (!personaName) return { kind: 'error', text: t.saveUsage }
856
+ await commitConfig((current) => {
857
+ const personas = clonePersonas(current.personas)
858
+ personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
859
+ return { ...current, personas }
860
+ })
861
+ return { kind: 'success', text: t.saveDone(personaName) }
862
+ }
863
+
864
+ if (first === 'use') {
865
+ const personaName = sanitizePersonaName(rest)
866
+ if (!personaName) return { kind: 'error', text: t.useUsage }
867
+ if (!hasOwn(config.personas || {}, personaName)) {
868
+ return { kind: 'error', text: t.personaMissing(personaName) }
869
+ }
870
+ // 预设值保存时已校验;此处再过一遍白名单,防御手改文件等异常数据
871
+ const { patch } = sanitizeConfig(pickPersonaValues(config.personas[personaName]))
872
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, ...patch }))
873
+ if (changed.length === 0) {
874
+ return { kind: 'success', text: t.useUnchanged(personaName) }
583
875
  }
584
- const updated = await commitConfig(c => ({ ...c, nickname: patch.nickname }))
585
- // 更新系统提示词,并注入所有活动会话
586
876
  refreshPromptAndInject(ctx, updated)
587
- return { kind: 'success', text: t.nicknameDone(patch.nickname) }
877
+ return { kind: 'success', text: t.useDone(personaName) }
588
878
  }
879
+
880
+ if (first === 'list') {
881
+ const personas = config.personas || {}
882
+ const names = Object.keys(personas).sort()
883
+ if (names.length === 0) {
884
+ return { kind: 'success', text: `${t.listTitle}\n${t.listEmpty}` }
885
+ }
886
+ const activeName = findActivePersonaName(config)
887
+ const lines = names.map((personaName) => {
888
+ const entry = personas[personaName]
889
+ const styleText = STYLE_VALUES.includes(entry.style) ? (t.styleNames[entry.style] || entry.style) : (entry.style || '-')
890
+ const nickText = entry.nickname || '-'
891
+ return `${personaName === activeName ? '✔ ' : '• '}${personaName} | ${t.styleLabel}=${styleText} | ${t.nicknameLabel}=${nickText}`
892
+ })
893
+ return { kind: 'success', text: `${t.listTitle} [${names.length}]\n${lines.join('\n')}` }
894
+ }
895
+
896
+ if (first === 'del' || first === 'delete' || first === 'rm') {
897
+ const personaName = sanitizePersonaName(rest)
898
+ if (!personaName) return { kind: 'error', text: t.delUsage }
899
+ if (!hasOwn(config.personas || {}, personaName)) {
900
+ return { kind: 'error', text: t.personaMissing(personaName) }
901
+ }
902
+ await commitConfig((current) => {
903
+ const personas = clonePersonas(current.personas)
904
+ delete personas[personaName]
905
+ return { ...current, personas }
906
+ })
907
+ return { kind: 'success', text: t.delDone(personaName) }
908
+ }
909
+
910
+ if (first === 'set') {
911
+ const pairs = parseSetArgs(rest)
912
+ if (!pairs || pairs.length === 0) return { kind: 'error', text: t.setUsage }
913
+ const rawPatch = {}
914
+ for (const [key, value] of pairs) {
915
+ if (key === 'enabled') {
916
+ const flag = value.toLowerCase()
917
+ if (['true', '1', 'on', 'yes'].includes(flag)) rawPatch.enabled = true
918
+ else if (['false', '0', 'off', 'no'].includes(flag)) rawPatch.enabled = false
919
+ else return { kind: 'error', text: t.failed(t.invalidEnabled) }
920
+ } else {
921
+ rawPatch[key] = value
922
+ }
923
+ }
924
+ const { patch, errors } = sanitizeConfig(rawPatch)
925
+ const errorKeys = Object.keys(errors)
926
+ if (errorKeys.length > 0) {
927
+ return { kind: 'error', text: t.failed(`${errorKeys[0]}: ${errors[errorKeys[0]]}`) }
928
+ }
929
+ const droppedKeys = Object.keys(rawPatch).filter((key) => !(key in patch))
930
+ if (Object.keys(patch).length === 0) {
931
+ return { kind: 'error', text: t.failed(droppedKeys.length > 0 ? `${t.unknownField}: ${droppedKeys.join(', ')}` : t.setUsage) }
932
+ }
933
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, ...patch }))
934
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
935
+ return { kind: 'success', text: changed.length > 0 ? t.setDone(changed) : t.setNoChanges }
936
+ }
937
+
938
+ if (first === 'confirm') {
939
+ if (!pendingPersonaProposal) return { kind: 'error', text: t.noPending }
940
+ const proposal = pendingPersonaProposal
941
+ pendingPersonaProposal = null
942
+ const { config: updated, changed } = await commitConfig(c => ({ ...c, ...proposal.patch }))
943
+ if (changed.length > 0) refreshPromptAndInject(ctx, updated)
944
+ const detail = Object.keys(proposal.patch).map((key) => `${key}=${JSON.stringify(proposal.patch[key])}`).join(', ')
945
+ return { kind: 'success', text: t.confirmDone(detail) }
946
+ }
947
+
948
+ if (first === 'reject') {
949
+ if (!pendingPersonaProposal) return { kind: 'error', text: t.noPending }
950
+ pendingPersonaProposal = null
951
+ return { kind: 'success', text: t.rejectDone }
952
+ }
953
+
954
+ // 理论不可达:关键字表与上方分支一一对应
955
+ return { kind: 'error', text: t.help }
589
956
  } catch (err) {
590
957
  return { kind: 'error', text: commandMessages(await loadConfig()).failed(err.message) }
591
958
  }
@@ -690,6 +1057,10 @@ function registerTools(ctx) {
690
1057
  properties: {
691
1058
  ok: { type: 'boolean' },
692
1059
  message: { type: 'string' },
1060
+ pending: {
1061
+ type: 'boolean',
1062
+ description: '提议待确认(requireToolConfirmation 开启时返回,变更尚未生效)'
1063
+ },
693
1064
  changes: {
694
1065
  type: 'object',
695
1066
  description: '实际发生变化的字段',
@@ -712,32 +1083,53 @@ function registerTools(ctx) {
712
1083
  // 与 HTTP 保存共用同一套校验(白名单 / 类型 / 长度 / 枚举)
713
1084
  const { patch, errors } = sanitizeConfig(args)
714
1085
  if (Object.keys(errors).length > 0) {
715
- return { ok: false, message: '配置校验失败:' + formatFieldErrors(errors) }
1086
+ const T0 = promptTextOf(await loadConfig())
1087
+ return { ok: false, message: T0.toolValidationFailed + formatFieldErrors(errors) }
716
1088
  }
717
1089
  if (Object.keys(patch).length === 0) {
718
- return { ok: true, message: '没有要更改的设置' }
1090
+ const T0 = promptTextOf(await loadConfig())
1091
+ return { ok: true, message: T0.toolNoChanges }
719
1092
  }
720
1093
 
721
- // 在写队列内做 diff 与合并,避免与其他写入方(Web UI、/soul 命令)竞态
722
- let changes = null
723
- const updated = await commitConfig((current) => {
1094
+ // 确认模式:不落盘,返回待确认提议(/soul confirm 应用、/soul reject 拒绝)
1095
+ const snapshot = await loadConfig()
1096
+ if (snapshot.requireToolConfirmation) {
1097
+ const T0 = promptTextOf(snapshot)
1098
+ const pendingKeys = Object.keys(patch).filter((key) => snapshot[key] !== patch[key])
1099
+ if (pendingKeys.length === 0) {
1100
+ return { ok: true, message: T0.toolNoChanges }
1101
+ }
1102
+ const proposalChanges = Object.fromEntries(pendingKeys.map((key) => [key, patch[key]]))
1103
+ pendingPersonaProposal = { patch: proposalChanges, proposedAt: new Date().toISOString() }
1104
+ const detail = pendingKeys.map((key) => `${key}=${JSON.stringify(patch[key])}`).join(', ')
1105
+ return {
1106
+ ok: true,
1107
+ pending: true,
1108
+ changes: proposalChanges,
1109
+ message: `${T0.toolProposal}\n${detail}\n${T0.toolProposalHint}`
1110
+ }
1111
+ }
1112
+
1113
+ // 写队列内合并,避免与其他写入方(Web UI、/soul 命令)竞态;
1114
+ // changed 由队列任务统一 diff 得出
1115
+ const { config: updated, changed } = await commitConfig((current) => {
724
1116
  const next = { ...current }
725
- changes = {}
726
1117
  for (const key of Object.keys(patch)) {
727
- if (current[key] !== patch[key]) {
728
- next[key] = patch[key]
729
- changes[key] = patch[key]
730
- }
1118
+ next[key] = patch[key]
731
1119
  }
732
1120
  return next
733
1121
  })
734
1122
 
735
- if (!changes || Object.keys(changes).length === 0) {
736
- return { ok: true, message: '没有要更改的设置' }
1123
+ if (changed.length === 0) {
1124
+ return { ok: true, message: promptTextOf(updated).toolNoChanges }
737
1125
  }
738
1126
 
739
1127
  refreshPromptAndInject(ctx, updated)
740
- return { ok: true, message: '已更新个性化设置', changes }
1128
+ return {
1129
+ ok: true,
1130
+ message: promptTextOf(updated).toolUpdated,
1131
+ changes: Object.fromEntries(changed.map(key => [key, updated[key]]))
1132
+ }
741
1133
  } catch (err) {
742
1134
  return { ok: false, message: String(err.message || err) }
743
1135
  }
@@ -761,24 +1153,27 @@ export async function apply(ctx) {
761
1153
  return await loadConfig()
762
1154
  },
763
1155
  async updateConfig(newConfig) {
764
- // 与 HTTP 保存共用同一套校验;失败时抛错给服务调用方
1156
+ // 与 HTTP 保存共用同一套校验;失败时抛错给服务调用方;返回更新后的配置
765
1157
  const { patch, errors } = sanitizeConfig(newConfig)
766
1158
  if (Object.keys(errors).length > 0) {
767
1159
  throw new Error('配置校验失败:' + formatFieldErrors(errors))
768
1160
  }
769
- return await commitConfig(current => ({ ...current, ...patch }))
1161
+ const { config } = await commitConfig(current => ({ ...current, ...patch }))
1162
+ return config
770
1163
  },
771
1164
  async getSystemPrompt() {
772
1165
  const config = await loadConfig()
773
1166
  return compilePrompt(config)
774
1167
  },
775
1168
  async resetConfig() {
776
- return await commitConfig(() => ({ ...DEFAULT_CONFIG }))
1169
+ const { config } = await commitConfig(current => defaultConfigPreservingPersonas(current))
1170
+ return config
777
1171
  }
778
1172
  })
779
1173
 
780
1174
  // 注册各模块
781
1175
  registerRoutes(ctx)
1176
+ registerPersonaRoutes(ctx)
782
1177
  registerCommands(ctx)
783
1178
  registerSystemPrompt(ctx)
784
1179
  registerTools(ctx)