dsh-soul 0.7.2 → 0.8.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
@@ -29,13 +29,43 @@ import {
29
29
  LANGUAGE_VALUES,
30
30
  PERSONA_FIELDS,
31
31
  PERSONA_NAME_MAX,
32
+ PROMPT_INPUT_FIELDS,
32
33
  migrateConfig,
33
34
  sanitizeConfig,
34
35
  sanitizePersonaName
35
36
  } from './lib/config.mjs'
37
+ import {
38
+ bindingsPathFor,
39
+ bindingRefusal,
40
+ compareKey,
41
+ MAX_BINDINGS,
42
+ MAX_PATH_LENGTH,
43
+ normalizeBindingEntry,
44
+ normalizeDirPath,
45
+ readBindingsFile,
46
+ resolveDirIdentity,
47
+ resolvePersonaForCwd,
48
+ writeBindingsFile
49
+ } from './lib/bindings.mjs'
36
50
  import { createInjectionSource } from './lib/injection.mjs'
37
51
  import { isBuiltinPersona, builtinPersonaNames, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
38
52
  import { configWriteRefusal, describeConfigFailure, moveAsideConfigFile, readConfigFile, writeConfigFile } from './lib/store.mjs'
53
+ import {
54
+ buildHistoryEntry,
55
+ clearHistoryFile,
56
+ createEntryId,
57
+ diffFields,
58
+ diffPersonas,
59
+ findEntryIndex,
60
+ hashConfig,
61
+ historyPathFor,
62
+ MAX_READ_LINES,
63
+ planRollback,
64
+ randomSuffix,
65
+ readHistoryFile,
66
+ recordHistoryEntry,
67
+ toHistoryResponse
68
+ } from './lib/history.mjs'
39
69
 
40
70
  const name = 'soul'
41
71
 
@@ -44,6 +74,19 @@ function configPath() {
44
74
  return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'soul-config.json')
45
75
  }
46
76
 
77
+ // 变更记录的落盘路径:配置文件的**兄弟文件**(soul-config.json → soul-config.history.jsonl)。
78
+ // 与配置同目录而不塞进配置里,理由见 DESIGN-audit-rollback.md §4.1。
79
+ function historyPath() {
80
+ return historyPathFor(configPath())
81
+ }
82
+
83
+ // 最近一次「变更记录没能记下来」的原因(记成功则清空)。
84
+ //
85
+ // 审计失败绝不能连带配置写失败 —— 那会把「记不下来」升级成「改不了」,
86
+ // 而配置写入是这个插件的核心功能。但也不能装作没发生:原因经
87
+ // GET /api/soul/history 的 warning 字段回传,设置页会原样显示。
88
+ let lastHistoryFailure = null
89
+
47
90
  // 版本号:唯一来源是 package.json。
48
91
  //
49
92
  // 刻意**不**在这里再写一个常量 —— 需要手工同步的版本号已经有三处
@@ -371,26 +414,115 @@ function enqueueConfigWrite(task) {
371
414
  return run
372
415
  }
373
416
 
374
- // 纯客户端外观字段:变更它们不需要刷新系统提示词,也不需要向活动会话注入快照;
417
+ // 不影响提示词的字段:变更它们不需要刷新系统提示词,也不需要向活动会话注入快照;
375
418
  // 但它们仍是实际配置变更,必须计入 changed(供调用方判断「已保存 / 无变化」并驱动前端 dirty)。
376
- const NON_PROMPT_FIELDS = new Set(['trailEnabled', 'trailColor', 'trailSpeed', 'trailWidth'])
419
+ //
420
+ // 由 DEFAULT_CONFIG 减去 PROMPT_INPUT_FIELDS **推导**,而不是手写清单。
421
+ // 手写清单的代价已经发生过两次:trail* 四项之外,requireToolConfirmation 与
422
+ // historyMaxEntries 都曾被漏在外面 —— 后果是改一次「保留条数」会重新注册 section
423
+ // 并向所有活动会话注入一条配置更新消息,用户看到的是「我只调了个容量,Agent 却被
424
+ // 通知了一遍配置变了」。这类「漏登记」不会报错,只会安静地多注入。
425
+ //
426
+ // 推导的安全前提是 compilePrompt 只读 PROMPT_INPUT_FIELDS 里的字段,该前提由
427
+ // scripts/verify-config.mjs 的「编译源码读取的每个 config.X 都必须已登记」钉住。
428
+ //
429
+ // 未知键(不在 DEFAULT_CONFIG 里)会落在「影响提示词」一侧 —— 方向是安全的:
430
+ // 最坏是多重启一次提示词,而不是改了不生效。
431
+ const NON_PROMPT_FIELDS = new Set(
432
+ Object.keys(DEFAULT_CONFIG).filter((key) => !PROMPT_INPUT_FIELDS.includes(key))
433
+ )
377
434
 
378
435
  // 在写队列中执行一次配置更新:mutate 收到当前配置的浅拷贝,返回新配置对象。
379
436
  // 注意 mutate 必须在队列任务内完成全部读取与合并,不要在队列外提前读取配置。
380
437
  // 返回 { config, changed, promptChanged }:
381
438
  // - changed:实际发生变化的字段名数组(不含 personas 预设库),供调用方判断有无变更
382
439
  // - promptChanged:changed 中会影响 Agent 行为的子集;为空时应跳过提示词刷新与会话注入
383
- async function commitConfig(mutate) {
440
+ //
441
+ // 第二参数 source 是**变更记录的归属**,形如 { source, action }:source 是渠道
442
+ // (web / command / tool / service / persona / rollback / restorePersona),
443
+ // action 是动作(save / set / use / …)。两个都是白名单枚举,写进条目与读回条目时各校验一次。
444
+ //
445
+ // 为什么必须由调用方显式给出:同一次「改了 style」可能是网页保存、斜杠命令、Agent 工具
446
+ // 或其它插件调用,事后从配置差异里是**推不出来**的。审计的价值有一半在于回答
447
+ // 「是谁改的」—— 默认值会让这半个价值静默消失,所以这里刻意不给默认值。
448
+ async function commitConfig(mutate, source) {
384
449
  return enqueueConfigWrite(async () => {
385
450
  const current = await loadConfig()
386
- const next = mutate({ ...current })
451
+ // mutate 允许返回 Promise:撤销要在「读记录 → 规划 → 写配置」之间保持原子,
452
+ // 而记录必须在**队列内**读。放在队列外读会有一个窗口——两次撤销并发时,
453
+ // 后者的规划建立在已被前者改过的配置上,指纹校验会给出看起来像「数据损坏」的
454
+ // diverged,排查方向完全跑偏。
455
+ const next = await mutate({ ...current })
387
456
  const changed = diffKeys(current, next)
388
457
  const promptChanged = changed.filter((key) => !NON_PROMPT_FIELDS.has(key))
389
458
  const config = await saveConfig(next)
390
- return { config, changed, promptChanged }
459
+
460
+ // 差分必须在 saveConfig **之后**、且以它返回的配置为基准:saveConfig 会剔除
461
+ // tone / presets / examples 三个废弃字段,拿 mutate 的产物去比会把它们误报成变更。
462
+ const entry = await auditCommit(current, config, source, config.historyMaxEntries)
463
+
464
+ return { config, changed, promptChanged, entry }
391
465
  })
392
466
  }
393
467
 
468
+ // 路由内主动拒绝:从队列任务里抛出即可中止本次提交(不写盘),由 handler 按状态码回话。
469
+ // 不用返回一个「什么都没改」的配置来收场 —— 那会照样重写一次配置文件,
470
+ // 让一次失败的操作在磁盘上留下痕迹。
471
+ function httpError(status, message) {
472
+ const err = new Error(message)
473
+ err.httpStatus = status
474
+ return err
475
+ }
476
+
477
+ // 把一次成功的提交记进变更记录。
478
+ //
479
+ // 时机与失败处理(DESIGN-audit-rollback.md §5):调用点已在 saveConfig 之后,
480
+ // 所以「配置写失败却留下记录」在结构上不可能。反过来「配置已写、记录没写成」是可能的
481
+ // (磁盘只读、记录文件被占用)—— 此时把原因记进 lastHistoryFailure 并**继续返回成功**,
482
+ // 因为配置确实已经改了。
483
+ async function auditCommit(current, config, source, maxEntries) {
484
+ try {
485
+ const fields = diffFields(current, config)
486
+ const personas = diffPersonas(current.personas, config.personas)
487
+
488
+ // 「什么都没变」必须在构造条目**之前**判掉,不能靠 buildHistoryEntry 返回 null 来表达。
489
+ // 后者把两种情况混成了同一个返回值:真的没变,和条目因为 shape 不对被拒
490
+ // (例如某个调用点漏标 source)—— 后者是代码缺陷,混进来就会变成
491
+ // 「配置改了、记录里没有、也没有任何告警」。这条判据把缺陷从静默变响亮。
492
+ const changedAny =
493
+ Object.keys(fields.after).length > 0 ||
494
+ personas.added.length > 0 || personas.updated.length > 0 || personas.removed.length > 0
495
+ if (!changedAny) {
496
+ lastHistoryFailure = null
497
+ return null
498
+ }
499
+
500
+ const entry = buildHistoryEntry({
501
+ id: createEntryId(Date.now(), randomSuffix()),
502
+ at: new Date().toISOString(),
503
+ source: source && source.source,
504
+ action: source && source.action,
505
+ label: source && source.label,
506
+ configHash: hashConfig(config),
507
+ before: fields.before,
508
+ after: fields.after,
509
+ personas
510
+ })
511
+ if (!entry) {
512
+ // 有变化却构造不出条目 ⇒ 一定是调用点的 source/action 没标或标错。
513
+ // 报出来而不是当作「无变化」跳过:审计缺一条而没人知道,比留一条错的更糟。
514
+ lastHistoryFailure = `变更记录条目构造失败(source=${JSON.stringify(source)}),本次改动未被记录`
515
+ return null
516
+ }
517
+ await recordHistoryEntry(historyPath(), entry, maxEntries)
518
+ lastHistoryFailure = null
519
+ return entry
520
+ } catch (err) {
521
+ lastHistoryFailure = String((err && err.message) || err)
522
+ return null
523
+ }
524
+ }
525
+
394
526
  // 比较两份配置的差异字段名(浅比较;soul 配置为扁平对象)。
395
527
  // personas(人设预设库)不属于活动配置:库的增删改不应触发提示词刷新与会话注入。
396
528
  function diffKeys(a, b) {
@@ -420,13 +552,16 @@ function defaultConfigPreservingPersonas(current) {
420
552
  //
421
553
  // 复用 commitConfig 的「读—改—写」,不另写一份;写入失败时恢复失败标记,
422
554
  // 避免把「文件仍然不可用」误报成正常状态。
423
- async function resetConfigWithEscape() {
555
+ //
556
+ // source 由调用方给(web 重置 / 斜杠命令重置 / 其它插件调用),穿透给 commitConfig
557
+ // 用于变更记录 —— 三个入口都是「重置」,但来源不同,记录里要分得开。
558
+ async function resetConfigWithEscape(source) {
424
559
  const failure = configLoadFailure
425
560
  const backupPath = failure ? await moveAsideConfigFile(configPath()) : null
426
561
  if (failure) configLoadFailure = null
427
562
 
428
563
  try {
429
- const result = await commitConfig((current) => defaultConfigPreservingPersonas(current))
564
+ const result = await commitConfig((current) => defaultConfigPreservingPersonas(current), source)
430
565
  return { ...result, backupPath }
431
566
  } catch (err) {
432
567
  if (failure) configLoadFailure = failure
@@ -587,14 +722,23 @@ function injectPromptToAllAgents(ctx, config) {
587
722
  }
588
723
 
589
724
  const T = promptTextOf(config)
590
- const prompt = compilePrompt(config)
591
- const snapshotText = prompt || T.injectDisabled
592
725
 
593
726
  let delivered = 0
594
727
  let failed = 0
595
728
  let lastError = null
596
729
 
597
730
  for (const agent of listed) {
731
+ // **每个会话各自编译**,不能共用一份文本。
732
+ //
733
+ // 工作区绑定让「配置」不再是全局唯一的:A 项目会话该拿到「资深架构师」,B 项目会话
734
+ // 该拿到「极简主义者」。若在这里算一份全局文本发给所有人,B 项目会收到 A 的人设 ——
735
+ // 这比不注入更糟(不注入时它至少还是全局配置,与用户以为的一致)。
736
+ //
737
+ // 拿不到 cwd(会话头无该字段)时 configForCwd 返回原配置,行为与绑定功能上线前一致。
738
+ const perAgent = configForCwd(config, cwdOfAgent(agent)).config
739
+ const prompt = compilePrompt(perAgent)
740
+ const snapshotText = prompt || T.injectDisabled
741
+
598
742
  try {
599
743
  agent.inject(createUserMessage({
600
744
  content: [{
@@ -643,6 +787,162 @@ function refreshPromptAndInject(ctx, config) {
643
787
  injectPromptToAllAgents(ctx, config)
644
788
  }
645
789
 
790
+ // ==================== 工作区绑定:按目录解析人设 ====================
791
+ //
792
+ // 三层解析(DESIGN-persona-scope.md §5):会话显式覆盖 → 目录绑定(最长前缀)→ 全局配置。
793
+ // 0.8.1-b 落地的是中间那层;会话覆盖(0.8.1-c/d)尚未接入,接入点见 resolveForCwd 的注释。
794
+ //
795
+ // 为什么绑定表要单独缓存而不是每次读盘:本节的两个消费者都在**每一轮装配**上 ——
796
+ // system prompt section 的 text provider 每轮都被调用一次,活动会话注入每次保存各一次。
797
+ // 读盘 + 200 次 realpath 放进这条路径是不可接受的,所以绑定表在加载时规范化一次。
798
+
799
+ // 绑定文件路径(configPath() 的兄弟文件)。
800
+ function bindingsPath() {
801
+ return bindingsPathFor(configPath())
802
+ }
803
+
804
+ // 绑定表读数。null = 还没读过(与「读过了、确实是空表」必须分得开,
805
+ // 后者意味着「用户一条都没绑」,前者意味着「还没问过磁盘」)。
806
+ let bindingsCache = null
807
+
808
+ // 最近一次绑定加载失败 / 有坏条目的说明(无问题为 null)。
809
+ let bindingsLoadFailure = null
810
+
811
+ // 命中判定的比较表。
812
+ //
813
+ // 这里有两个键集合,都指向同一条绑定:
814
+ // · **展示键**:绑定表里原样存着的形式,UI 与用户看到的就是它;
815
+ // · **身份键**:`realpath` 解析后的形式(解掉符号链接 / Junction;Windows 上还会
816
+ // 规范化盘符大小写)。
817
+ //
818
+ // 为什么要两个都进比较表 —— 两边各自可能缺席一次解析:
819
+ // · 会话 cwd 由 Node 的 `process.cwd()` 提供(`GetCurrentDirectory` 返回的是**规范**
820
+ // 路径),而绑定目录是用户在选择器里点出来的,很可能是 Junction 路径。
821
+ // 典型失配:cwd = `D:\真实目录`、绑定键 = `D:\junction入口`。
822
+ // · 反过来,用户手工粘贴的路径可能本就是规范路径,而会话 cwd 落在 Junction 里。
823
+ // 两个键都留着,任一侧成立即命中;`bindingsDisplayOf` 负责把命中结果翻译回展示键,
824
+ // 因为用户不认得 realpath 吐出来的那个名字。
825
+ //
826
+ // **不对 cwd 做 realpath**:text provider 必须是**同步**的(见 registerSystemPrompt),
827
+ // 而 realpath 是异步 I/O。把身份解析全部放在**键**侧,这个约束就消失了 ——
828
+ // 键在加载时算一次,之后每轮装配都只是字符串比较。
829
+ let bindingsCompareTable = null
830
+
831
+ // 身份键 → 展示键(仅当两者不同时才有一条)。
832
+ let bindingsDisplayOf = null
833
+
834
+ // 解析绑定表:读盘 + 为每条键算出身份形态。
835
+ //
836
+ // 失败时**以空表继续**并把原因记在 bindingsLoadFailure 上,绝不抛错:
837
+ // 绑定是可选增强,读不出来不该让整个插件的装配流程崩掉(与 loadConfig 的策略不同,
838
+ // 因为配置是用户唯一的数据副本,而绑定不是)。
839
+ //
840
+ // 失败时**不写 bindingsCache**,与 loadConfig 同款:用户把文件改对之后,下一次
841
+ // 加载(绑定路由写完表之后总会重新加载)就能自动恢复,不必重启 DSH。
842
+ // 此时比较表被清成空表 —— 宁可暂时不生效,也不能拿上一次的旧表继续命中。
843
+ async function loadBindings() {
844
+ const filePath = bindingsPath()
845
+ const { bindings, dropped, failure } = await readBindingsFile(filePath)
846
+ bindingsLoadFailure = failure || (dropped.length > 0 ? `${dropped.length} 条绑定因格式问题被忽略。` : null)
847
+
848
+ if (failure) {
849
+ bindingsCompareTable = {}
850
+ bindingsDisplayOf = new Map()
851
+ return { bindings: {}, dropped, failure, filePath }
852
+ }
853
+
854
+ // 计算身份键。并发发起(最多 MAX_BINDINGS=200 条,realpath 很轻),
855
+ // 但 resolveDirIdentity 自身绝不抛错,所以这里不需要 per-item 的 try/catch。
856
+ const compareTable = { ...bindings }
857
+ const displayOf = new Map()
858
+ const keys = Object.keys(bindings)
859
+ const identities = await Promise.all(keys.map(dir => resolveDirIdentity(dir)))
860
+
861
+ for (let i = 0; i < keys.length; i++) {
862
+ const dir = keys[i]
863
+ const identity = identities[i]
864
+ // 只有真的不同才加别键:同一条绑定占两个键位会让 shadowed 出现重复项。
865
+ if (!identity || compareKey(identity) === compareKey(dir)) continue
866
+ // 身份键撞上另一条展示键时**不覆盖** —— 展示键是用户亲手写的那个,
867
+ // 优先级更高;被挡住的身份键只是少一条别名,判定方向安全。
868
+ if (hasOwn(compareTable, identity)) continue
869
+ compareTable[identity] = bindings[dir]
870
+ displayOf.set(identity, dir)
871
+ }
872
+
873
+ bindingsCompareTable = compareTable
874
+ bindingsDisplayOf = displayOf
875
+ bindingsCache = { bindings, dropped, failure: null, filePath }
876
+ return bindingsCache
877
+ }
878
+
879
+ // 按目录解析该用什么人设(三层解析的「目录层」)。
880
+ //
881
+ // 返回 null 表示没有可用绑定 ⇒ 调用方回落全局配置(这是绝大多数用户的常态)。
882
+ // 返回对象时:
883
+ // · dir / persona —— 真正生效的那条绑定;
884
+ // · shadowed —— 命中了、但人设已不存在的更具体绑定(不生效,但必须能说出来)。
885
+ //
886
+ // 本函数是**同步**的:它每轮装配都被调用,且 text provider 不允许是异步的。
887
+ // 因此读盘只能发生在装配之外(apply() 启动时、绑定路由写完之后),见 ensureBindings。
888
+ async function ensureBindings() {
889
+ if (!bindingsCache) await loadBindings()
890
+ return bindingsCache
891
+ }
892
+
893
+ function resolveBindingForCwd(cwd, userPersonas) {
894
+ if (!bindingsCompareTable || !bindingsDisplayOf) return null
895
+ const normalized = normalizeDirPath(cwd)
896
+ if (!normalized) return null
897
+
898
+ const resolved = resolvePersonaForCwd(normalized, bindingsCompareTable, userPersonas)
899
+ if (!resolved) return null
900
+
901
+ // 命中身份键时回报展示键(用户认得的是他自己写的那个名字)。
902
+ const display = dir => (bindingsDisplayOf.get(dir) || dir)
903
+ const seen = new Set()
904
+ const shadowed = []
905
+ for (const item of resolved.shadowed) {
906
+ const dir = display(item.dir)
907
+ // 同一条绑定的展示键与身份键可能都命中,去重后 UI 才不会把一条失效绑定画两遍。
908
+ if (seen.has(dir)) continue
909
+ seen.add(dir)
910
+ shadowed.push({ ...item, dir })
911
+ }
912
+
913
+ return { dir: display(resolved.dir), persona: resolved.persona, shadowed }
914
+ }
915
+
916
+ // 把「全局配置 + 一次目录解析结果」合成为该目录实际生效的配置。
917
+ //
918
+ // 用预设的**部分覆盖**语义(与 /api/soul/personas/use 同一条规则):只覆盖条目实际
919
+ // 声明过的字段,未声明的保持全局值。这条复用不是巧合 —— 用户在设置页点「使用」
920
+ // 一个预设时看到的效果,必须与他在该目录里自动拿到的效果**逐字段一致**,
921
+ // 否则「绑定生效了吗」将无法用任何已有操作来验证。
922
+ //
923
+ // `enabled` 刻意**不在**覆盖范围内(它不属于 PERSONA_FIELDS):全局总开关始终说话,
924
+ // 关掉插件后绑定的目录也不会偷偷注入人设。
925
+ function configForCwd(base, cwd) {
926
+ const resolved = resolveBindingForCwd(cwd, base.personas)
927
+ if (!resolved) return { config: base, binding: null }
928
+ const persona = resolvePersona(resolved.persona, base.personas)
929
+ if (!persona) return { config: base, binding: null }
930
+ const { patch } = sanitizeConfig(pickPersonaValues(persona))
931
+ return { config: { ...base, ...patch }, binding: resolved }
932
+ }
933
+
934
+ // 从装配上下文里取该会话的工作目录。
935
+ //
936
+ // 两个都必须容忍 undefined:
937
+ // · `ctx` —— 宿主在 diagnostics 装配时**不传 agent**(runtime-types.d.ts:14-19 的注释
938
+ // 写明 `absent on diagnostics`)。此时回落全局配置,绝不抛错。
939
+ // · `cwd` —— 会话头里可能没有这个字段。取不到时任何绑定都不会命中,
940
+ // 于是行为与「没有绑定功能」完全一致(失败方向安全)。
941
+ function cwdOfAgent(agent) {
942
+ const cwd = agent?.session?.header?.cwd
943
+ return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : null
944
+ }
945
+
646
946
  // ==================== HTTP 路由 ====================
647
947
 
648
948
  function registerRoutes(ctx) {
@@ -687,7 +987,7 @@ function registerRoutes(ctx) {
687
987
  }
688
988
 
689
989
  // 写队列内「读—改—写」,只合并通过校验的字段(未知字段不落盘)
690
- const { config: updated, changed, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
990
+ const { config: updated, changed, promptChanged } = await commitConfig(current => ({ ...current, ...patch }), { source: 'web', action: 'save' })
691
991
 
692
992
  // 仅影响 Agent 行为的字段变化才刷新提示词并注入会话;
693
993
  // 光轨等纯外观字段只更新配置,不向会话堆积注入快照
@@ -800,7 +1100,7 @@ function registerRoutes(ctx) {
800
1100
  // 经由逃生口:配置损坏时这是设置页唯一还能成功的写操作。
801
1101
  // backupPath 非 null 说明刚才确实丢弃了一份损坏的配置(已备份),
802
1102
  // 客户端据此给出「备份在哪」的提示,而不是让用户以为数据凭空消失。
803
- const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape()
1103
+ const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape({ source: 'web', action: 'reset' })
804
1104
 
805
1105
  // 仅影响 Agent 行为的字段变化才刷新提示词并注入会话
806
1106
  if (promptChanged.length > 0) {
@@ -928,10 +1228,18 @@ function findActivePersonaName(config) {
928
1228
  // 语义选择:应用**合法部分**并如实回报被丢弃的字段,而不是整单拒绝 —— 后者会让一个
929
1229
  // 只差一个字段的旧预设彻底不可用,代价更高;而「部分应用 + 明示」与 /api/soul/prompt/
930
1230
  // preview 的 invalid 约定一致。
1231
+ //
1232
+ // 来源固定为 persona/use:两个调用入口(网页「使用」与 /soul use)是同一个动作,
1233
+ // 用户视角下「用了个预设」这件事本身比「从哪个入口点的」更重要,故不细分。
931
1234
  async function applyPersonaEntry(ctx, entry) {
932
1235
  const { patch, errors } = sanitizeConfig(pickPersonaValues(entry))
933
1236
  const invalid = Object.keys(errors)
934
- const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
1237
+ // label 带上预设名:审计里「应用预设」这条记录光有字段差,缺「应用的是什么」——
1238
+ // 用户问「昨晚我切到了哪个人设」时,字段差答不上来。
1239
+ const { config: updated, changed, promptChanged } = await commitConfig(
1240
+ c => ({ ...c, ...patch }),
1241
+ { source: 'persona', action: 'use', label: entry && typeof entry.name === 'string' ? entry.name : undefined }
1242
+ )
935
1243
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
936
1244
  // applied 是「通过校验的字段数」——与 changed(实际产生差异的字段)不同:
937
1245
  // 全部非法时 applied 为 0,此时不能报「已应用」,也不能报「与当前配置一致」。
@@ -1001,7 +1309,7 @@ function registerPersonaRoutes(ctx) {
1001
1309
  const personas = clonePersonas(current.personas)
1002
1310
  personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
1003
1311
  return { ...current, personas }
1004
- })
1312
+ }, { source: 'web', action: 'savePreset' })
1005
1313
  send(res, 200, { ok: true, ...personaLibrary(updated) })
1006
1314
  } catch (err) {
1007
1315
  send(res, 500, { ok: false, error: String(err.message || err) })
@@ -1080,7 +1388,7 @@ function registerPersonaRoutes(ctx) {
1080
1388
  const personas = clonePersonas(c.personas)
1081
1389
  delete personas[personaName]
1082
1390
  return { ...c, personas }
1083
- })
1391
+ }, { source: 'web', action: 'deletePreset' })
1084
1392
  send(res, 200, { ok: true, ...personaLibrary(updated) })
1085
1393
  } catch (err) {
1086
1394
  send(res, 500, { ok: false, error: String(err.message || err) })
@@ -1129,6 +1437,456 @@ function registerPersonaRoutes(ctx) {
1129
1437
  })
1130
1438
  }
1131
1439
 
1440
+ // ==================== 变更记录 ====================
1441
+
1442
+ // 解析 ?limit=。非法值一律当作「没给」而不是报错:这是一个纯展示参数,
1443
+ // 为一个拼错的查询串返回 400 只会让客户端多写一段没必要的错误处理。
1444
+ function parseLimit(rawUrl) {
1445
+ try {
1446
+ const raw = new URL(String(rawUrl || ''), 'http://localhost').searchParams.get('limit')
1447
+ if (raw === null) return null
1448
+ const value = Number.parseInt(raw, 10)
1449
+ if (!Number.isFinite(value) || value <= 0) return null
1450
+ return Math.min(value, MAX_READ_LINES)
1451
+ } catch {
1452
+ return null
1453
+ }
1454
+ }
1455
+
1456
+ // 变更记录的四条路由:列表 / 撤销 / 恢复预设 / 清空。
1457
+ //
1458
+ // 一律先校验 req.method(同文件其余写路由的规矩,来自 /api/soul/config/reset
1459
+ // 曾经任何方法——包括 GET——都能触发破坏性写入的教训)。
1460
+ //
1461
+ // 撤销与恢复预设都走唯一漏斗 commitConfig,于是「我撤销过一次」本身也留下一条记录:
1462
+ // 审计因此是链条而不是快照,能看出「改了、撤了、又改回去」。
1463
+ function registerHistoryRoutes(ctx) {
1464
+ ctx.inject(['webServer'], (wsCtx) => {
1465
+ const send = (res, status, body) => {
1466
+ res.writeHead(status, { 'content-type': 'application/json' })
1467
+ res.end(JSON.stringify(body))
1468
+ }
1469
+
1470
+ // 列表(最新在前)
1471
+ wsCtx.webServer.register({
1472
+ kind: 'exact',
1473
+ path: '/api/soul/history',
1474
+ handler: async (req, res) => {
1475
+ try {
1476
+ if (req.method !== 'GET') {
1477
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1478
+ return
1479
+ }
1480
+ const read = await readHistoryFile(historyPath())
1481
+ // max 必须来自**当前配置**(用户改了保留条数,UI 的「最多保留 N 条」就得跟着变);
1482
+ // 读配置失败时回退默认值 —— 列表读取不该因为配置层的抖动而 500。
1483
+ let maxEntries
1484
+ try {
1485
+ maxEntries = (await loadConfig()).historyMaxEntries
1486
+ } catch {
1487
+ maxEntries = undefined
1488
+ }
1489
+ const payload = toHistoryResponse(read, lastHistoryFailure, maxEntries)
1490
+ // ?limit= 只影响本次返回的条数,**不动 max** —— max 是容量上限(压实后保多少条),
1491
+ // 拿它当「本次有多少条」用,客户端会在两种含义之间猜。
1492
+ const limit = parseLimit(req.url)
1493
+ if (limit !== null && payload.entries.length > limit) {
1494
+ payload.entries = payload.entries.slice(0, limit)
1495
+ }
1496
+ send(res, 200, { ok: true, ...payload })
1497
+ } catch (err) {
1498
+ send(res, 500, { ok: false, error: String(err.message || err) })
1499
+ }
1500
+ }
1501
+ })
1502
+
1503
+ // 撤销:把配置退回到目标条目**发生之前**的状态(连带撤销它之后的所有改动)
1504
+ wsCtx.webServer.register({
1505
+ kind: 'exact',
1506
+ path: '/api/soul/history/rollback',
1507
+ handler: async (req, res) => {
1508
+ try {
1509
+ if (req.method !== 'POST') {
1510
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1511
+ return
1512
+ }
1513
+ const parsed = await readJsonBody(req)
1514
+ if (!parsed.ok) {
1515
+ send(res, parsed.status, { ok: false, error: parsed.error })
1516
+ return
1517
+ }
1518
+ const id = typeof (parsed.body && parsed.body.id) === 'string' ? parsed.body.id : ''
1519
+ if (!id) {
1520
+ send(res, 400, { ok: false, error: '缺少要撤销的记录 id' })
1521
+ return
1522
+ }
1523
+
1524
+ let plan = null
1525
+ const { config: updated, changed, promptChanged, entry } = await commitConfig(async (current) => {
1526
+ // 记录必须在**队列内**读:放到队列外读会与并发写入形成窗口,规划建立在
1527
+ // 已被别人改过的配置上,指纹校验随后报出一个看起来像「数据损坏」的
1528
+ // diverged,把排查方向带偏。
1529
+ const read = await readHistoryFile(historyPath())
1530
+ if (read.error) throw httpError(500, read.error)
1531
+ plan = planRollback(read.entries, current, id)
1532
+ if (!plan.ok) {
1533
+ if (plan.reason === 'missing') {
1534
+ throw httpError(400, `找不到这条变更记录:${id}。它可能已被清空或压实淘汰。`)
1535
+ }
1536
+ // diverged:配置与记录不同步(有人手改过配置文件)。不给一个猜出来的结果。
1537
+ throw httpError(409, '记录与当前配置对不上,无法撤销。可能有人手工改过配置文件。')
1538
+ }
1539
+ // 补丁为空是**合法**结果(目标那次改动已被后续改动改回来了),
1540
+ // 此时不改任何字段、也不会留下记录,由 unchanged 告诉客户端「已经是最新」。
1541
+ if (Object.keys(plan.patch).length === 0) return { ...current }
1542
+ return { ...current, ...plan.patch }
1543
+ }, { source: 'rollback', action: 'rollback' })
1544
+
1545
+ // 仅影响 Agent 行为的字段变化才刷新提示词注入(与其余写路径同一门控)
1546
+ if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1547
+
1548
+ send(res, 200, {
1549
+ ok: true,
1550
+ config: updated,
1551
+ changed,
1552
+ skipped: plan ? plan.skipped : [],
1553
+ reversed: plan ? plan.reversed : [],
1554
+ unchanged: changed.length === 0,
1555
+ entry,
1556
+ deliveryWarning: deliveryProblemText(updated)
1557
+ })
1558
+ } catch (err) {
1559
+ const status = err && err.httpStatus ? err.httpStatus : 500
1560
+ send(res, status, { ok: false, error: String((err && err.message) || err) })
1561
+ }
1562
+ }
1563
+ })
1564
+
1565
+ // 恢复预设:只对「删除预设」这类条目有意义(条目里带着被删条目的内容)
1566
+ wsCtx.webServer.register({
1567
+ kind: 'exact',
1568
+ path: '/api/soul/history/restore-persona',
1569
+ handler: async (req, res) => {
1570
+ try {
1571
+ if (req.method !== 'POST') {
1572
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1573
+ return
1574
+ }
1575
+ const parsed = await readJsonBody(req)
1576
+ if (!parsed.ok) {
1577
+ send(res, parsed.status, { ok: false, error: parsed.error })
1578
+ return
1579
+ }
1580
+ const id = typeof (parsed.body && parsed.body.id) === 'string' ? parsed.body.id : ''
1581
+ if (!id) {
1582
+ send(res, 400, { ok: false, error: '缺少记录 id' })
1583
+ return
1584
+ }
1585
+
1586
+ const restored = []
1587
+ const skipped = []
1588
+ const { config: updated, changed, entry } = await commitConfig(async (current) => {
1589
+ const read = await readHistoryFile(historyPath())
1590
+ if (read.error) throw httpError(500, read.error)
1591
+ const index = findEntryIndex(read.entries, id)
1592
+ if (index < 0) {
1593
+ throw httpError(400, `找不到这条变更记录:${id}。它可能已被清空或压实淘汰。`)
1594
+ }
1595
+ const removed = read.entries[index].personas.removed
1596
+ if (removed.length === 0) {
1597
+ throw httpError(400, '这条记录里没有被删除的预设,无法恢复。')
1598
+ }
1599
+ const personas = clonePersonas(current.personas)
1600
+ for (const item of removed) {
1601
+ // 逐个校验、逐个说明为什么没恢复。**绝不静默覆盖同名条目**:
1602
+ // 删掉「架构师」之后完全可能又新建了一个同名但内容不同的预设,
1603
+ // 此时「恢复」若是直接写回,等于把用户后来的编辑悄悄扔掉。
1604
+ const personaName = sanitizePersonaName(item.name)
1605
+ if (!personaName) {
1606
+ skipped.push({ name: item.name, reason: 'invalid-name' })
1607
+ continue
1608
+ }
1609
+ if (isBuiltinPersona(personaName)) {
1610
+ skipped.push({ name: personaName, reason: 'builtin' })
1611
+ continue
1612
+ }
1613
+ if (hasOwn(personas, personaName)) {
1614
+ skipped.push({ name: personaName, reason: 'exists' })
1615
+ continue
1616
+ }
1617
+ personas[personaName] = { ...item.entry }
1618
+ restored.push(personaName)
1619
+ }
1620
+ if (restored.length === 0) {
1621
+ throw httpError(400, `没有可恢复的预设:${skipped.map((item) => item.name).join('、')}`)
1622
+ }
1623
+ return { ...current, personas }
1624
+ }, { source: 'restorePersona', action: 'restorePreset' })
1625
+
1626
+ // 预设库变化不触发会话注入(diffKeys 本就跳过 personas),故此处不刷新提示词
1627
+ send(res, 200, { ok: true, restored, skipped, changed, entry, ...personaLibrary(updated) })
1628
+ } catch (err) {
1629
+ const status = err && err.httpStatus ? err.httpStatus : 500
1630
+ send(res, status, { ok: false, error: String((err && err.message) || err) })
1631
+ }
1632
+ }
1633
+ })
1634
+
1635
+ // 清空:隐私出口(记录里有昵称 / 职业 / 介绍 / 自定义指令的原文)
1636
+ wsCtx.webServer.register({
1637
+ kind: 'exact',
1638
+ path: '/api/soul/history/clear',
1639
+ handler: async (req, res) => {
1640
+ try {
1641
+ if (req.method !== 'POST') {
1642
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1643
+ return
1644
+ }
1645
+ // 借配置写队列串行:记录是在提交路径里写的,清空若不入队就会与并发的追加
1646
+ // 撞在一起 —— 最坏的结果是「刚清完又冒出一条」,用户会以为清空没生效。
1647
+ const removed = await enqueueConfigWrite(async () => {
1648
+ const count = await clearHistoryFile(historyPath())
1649
+ lastHistoryFailure = null
1650
+ return count
1651
+ })
1652
+ send(res, 200, { ok: true, removed })
1653
+ } catch (err) {
1654
+ send(res, 500, { ok: false, error: String(err.message || err) })
1655
+ }
1656
+ }
1657
+ })
1658
+ })
1659
+ }
1660
+
1661
+ // ==================== 工作区绑定路由 ====================
1662
+
1663
+ // 绑定表的写队列。与配置队列(enqueueConfigWrite)**刻意是两个实例**(设计 §4.3):
1664
+ // 两套数据在因果上无关 —— 绑定的写入不依赖配置的当前值,反之亦然 —— 共用一个队列
1665
+ // 只会把两者的延迟叠在一起,一次慢写的绑定请求会挡住配置保存。
1666
+ let bindingsWriteQueue = Promise.resolve()
1667
+
1668
+ function enqueueBindingsWrite(task) {
1669
+ const run = bindingsWriteQueue.then(task)
1670
+ bindingsWriteQueue = run.then(() => {}, () => {})
1671
+ return run
1672
+ }
1673
+
1674
+ // 活动会话的目录与解析结果,供设置页回答「我这个项目现在是谁」。
1675
+ //
1676
+ // 为什么必须由宿主算:设置页的槽位 scope 是 `root`(设计 §7.7),它**拿不到
1677
+ // sessionId**,也就拿不到会话 cwd。宿主遍历 agents.list() 是唯一权威的来源。
1678
+ //
1679
+ // 取 `session.header.cwd` 而不是工作区根:两者可能不同(工作区 D:\AliuProject、
1680
+ // 会话 cwd D:\AliuProject\dsh-plugin),显示错粒度会让用户绑错层级。
1681
+ function describeActiveSessions(ctx, config) {
1682
+ const out = []
1683
+ const agents = ctx.get('agents')
1684
+ if (!agents || typeof agents.list !== 'function') return out
1685
+
1686
+ let listed
1687
+ try {
1688
+ listed = agents.list()
1689
+ } catch {
1690
+ // 列不出活动会话不是错误:设置页只是少一段「当前用的谁」,仍能管理绑定表。
1691
+ return out
1692
+ }
1693
+
1694
+ for (const agent of listed) {
1695
+ const cwd = cwdOfAgent(agent)
1696
+ const id = agent && typeof agent.id === 'string' ? agent.id : null
1697
+
1698
+ if (!cwd) {
1699
+ // cwd 缺席是宿主允许的情况(会话没有工作目录)。必须如实说出来 ——
1700
+ // 让用户对着空白猜「为什么绑了没生效」,不如直接告诉他这个会话没有目录。
1701
+ out.push({ id, cwd: null, persona: findActivePersonaName(config), binding: null, shadowed: [] })
1702
+ continue
1703
+ }
1704
+
1705
+ const resolved = resolveBindingForCwd(cwd, config.personas)
1706
+ // 生效人设走与真实装配**同一条路径**(configForCwd → 部分覆盖 → findActivePersonaName),
1707
+ // 否则设置页显示的「生效人设」与实际注入的可能不一致 —— 那正是最难查的一类 bug。
1708
+ const effective = configForCwd(config, cwd).config
1709
+
1710
+ let binding = null
1711
+ if (resolved) {
1712
+ // 「直接绑定」与「继承自上级」的判别**必须在宿主做**:客户端拿原始串比较时,
1713
+ // Windows 上 `D:\项目A` 与 `d:\项目a` 会被判成不同目录,于是「就是本目录」
1714
+ // 被显示成「继承自上级」,用户会去找一条并不存在的父级绑定。
1715
+ const sameDir = compareKey(normalizeDirPath(resolved.dir)) === compareKey(normalizeDirPath(cwd))
1716
+ binding = { dir: resolved.dir, persona: resolved.persona, inherited: !sameDir }
1717
+ }
1718
+
1719
+ out.push({
1720
+ id,
1721
+ cwd,
1722
+ persona: findActivePersonaName(effective),
1723
+ binding,
1724
+ shadowed: resolved ? resolved.shadowed : []
1725
+ })
1726
+ }
1727
+
1728
+ return out
1729
+ }
1730
+
1731
+ // 绑定表的可读形态(列表 + 失效标注 + 解析结果)。
1732
+ async function bindingsView(ctx) {
1733
+ const cache = await ensureBindings()
1734
+ const config = await loadConfig()
1735
+
1736
+ // 失效引用:指向的人设已不存在。**不自动删除**(设计 §4.2)——用户可能只是临时
1737
+ // 删了那个预设、打算重命名回来。但必须显式标注,否则用户在目录里等一个永远
1738
+ // 不会来的人设。
1739
+ const invalid = []
1740
+ for (const dir of Object.keys(cache.bindings)) {
1741
+ const name = cache.bindings[dir]
1742
+ if (!resolvePersona(name, config.personas)) invalid.push({ dir, persona: name })
1743
+ }
1744
+
1745
+ return {
1746
+ bindings: cache.bindings,
1747
+ invalid,
1748
+ dropped: cache.dropped,
1749
+ // warning 是「整表没读进来」或「有条目被丢弃」,与「一条都没绑」是两件事。
1750
+ warning: bindingsLoadFailure,
1751
+ max: MAX_BINDINGS,
1752
+ maxPathLength: MAX_PATH_LENGTH,
1753
+ sessions: describeActiveSessions(ctx, config)
1754
+ }
1755
+ }
1756
+
1757
+ function registerBindingRoutes(ctx) {
1758
+ ctx.inject(['webServer'], (wsCtx) => {
1759
+ const send = (res, status, body) => {
1760
+ res.writeHead(status, { 'content-type': 'application/json' })
1761
+ res.end(JSON.stringify(body))
1762
+ }
1763
+
1764
+ // 写完之后统一收尾:重载绑定表 → 刷新提示词并注入。
1765
+ //
1766
+ // 这是**第三类**会改变提示词的事实(设计 §6.4):它不改 soul-config.json,
1767
+ // 所以 commitConfig 的 promptChanged 门控看不见它。绑定路由必须自己触发,
1768
+ // 否则「刚绑好,会话里却不生效,要新开会话才行」。
1769
+ const afterBindingsWrite = async (config) => {
1770
+ await loadBindings()
1771
+ const fresh = config || (await loadConfig())
1772
+ refreshPromptAndInject(ctx, fresh)
1773
+ }
1774
+
1775
+ // GET = 读取(绑定表 + 当前活动会话的解析结果);POST = 新增 / 改绑。
1776
+ //
1777
+ // 同一 (kind, path) 只能 register **一次**:宿主 dsh-host-webserver 的
1778
+ // `register()` 对重复路径直接 `throw new Error('webserver: duplicate exact
1779
+ // route "' + path + '"')`,而这里是 ctx.inject 回调 —— 一次重复注册会让
1780
+ // 整个插件装配失败。故方法分派必须写在 handler 内,这也正是本文件其余
1781
+ // 14 条路由的范式。
1782
+ wsCtx.webServer.register({
1783
+ kind: 'exact',
1784
+ path: '/api/soul/bindings',
1785
+ handler: async (req, res) => {
1786
+ if (req.method === 'GET') {
1787
+ try {
1788
+ send(res, 200, { ok: true, ...(await bindingsView(ctx)) })
1789
+ } catch (err) {
1790
+ send(res, 500, { ok: false, error: String(err.message || err) })
1791
+ }
1792
+ return
1793
+ }
1794
+ if (req.method !== 'POST') {
1795
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1796
+ return
1797
+ }
1798
+ try {
1799
+ const parsed = await readJsonBody(req)
1800
+ if (!parsed.ok) {
1801
+ send(res, parsed.status, { ok: false, error: parsed.error })
1802
+ return
1803
+ }
1804
+
1805
+ const config = await loadConfig()
1806
+ // 准入检查挑出三类拒绝(路径非法 / 过长 / 人设不存在),
1807
+ // 原因文案可直接展示 —— 客户端不得再包一层前缀。
1808
+ const refusal = bindingRefusal(parsed.body && parsed.body.dir, parsed.body && parsed.body.persona, config.personas)
1809
+ if (refusal) {
1810
+ send(res, 400, { ok: false, error: refusal })
1811
+ return
1812
+ }
1813
+ const entry = normalizeBindingEntry(parsed.body && parsed.body.dir, parsed.body && parsed.body.persona, config.personas)
1814
+ if (!entry) {
1815
+ send(res, 400, { ok: false, error: '绑定内容不合法,未写入。' })
1816
+ return
1817
+ }
1818
+
1819
+ await enqueueBindingsWrite(async () => {
1820
+ // 队列内读盘再合并:队列外读会与并发写入形成窗口,后写覆盖前写。
1821
+ const read = await readBindingsFile(bindingsPath())
1822
+ if (read.failure) throw httpError(500, read.failure)
1823
+ const table = { ...read.bindings }
1824
+ // 已存在的目录改绑不算新增,只有真新增才受条数上限约束 ——
1825
+ // 否则满表之后连改绑都做不了,用户只能先删再绑。
1826
+ const isNew = !hasOwn(table, entry.dir)
1827
+ if (isNew && Object.keys(table).length >= MAX_BINDINGS) {
1828
+ throw httpError(400, `绑定数量已达上限(${MAX_BINDINGS} 条),请先删除不再需要的绑定。`)
1829
+ }
1830
+ table[entry.dir] = entry.persona
1831
+ await writeBindingsFile(bindingsPath(), table)
1832
+ })
1833
+
1834
+ await afterBindingsWrite(config)
1835
+ send(res, 200, { ok: true, ...(await bindingsView(ctx)) })
1836
+ } catch (err) {
1837
+ // 队列内抛出的 httpError 带状态码;其余按 500 处理。
1838
+ send(res, err.httpStatus || 500, { ok: false, error: String(err.message || err) })
1839
+ }
1840
+ }
1841
+ })
1842
+
1843
+ // 解绑
1844
+ wsCtx.webServer.register({
1845
+ kind: 'exact',
1846
+ path: '/api/soul/bindings/delete',
1847
+ handler: async (req, res) => {
1848
+ try {
1849
+ if (req.method !== 'POST') {
1850
+ send(res, 405, { ok: false, error: 'Method not allowed' })
1851
+ return
1852
+ }
1853
+ const parsed = await readJsonBody(req)
1854
+ if (!parsed.ok) {
1855
+ send(res, parsed.status, { ok: false, error: parsed.error })
1856
+ return
1857
+ }
1858
+ const target = normalizeDirPath(parsed.body && parsed.body.dir)
1859
+ if (!target) {
1860
+ send(res, 400, { ok: false, error: '需要一个绝对路径才能解绑。' })
1861
+ return
1862
+ }
1863
+
1864
+ const config = await loadConfig()
1865
+ let removed = false
1866
+ await enqueueBindingsWrite(async () => {
1867
+ const read = await readBindingsFile(bindingsPath())
1868
+ if (read.failure) throw httpError(500, read.failure)
1869
+ const table = { ...read.bindings }
1870
+ // 按**比较键**找:用户点了列表里那一行,但配置里存的可能是另一种写法
1871
+ // (分隔符、尾分隔符、盘符大小写)。拿原串直接 delete 会「点了没反应」。
1872
+ const key = Object.keys(table).find(dir => compareKey(dir) === compareKey(target))
1873
+ if (key === undefined) return
1874
+ delete table[key]
1875
+ removed = true
1876
+ await writeBindingsFile(bindingsPath(), table)
1877
+ })
1878
+
1879
+ // 没删掉任何东西时**不刷新**:一次空解绑不该向所有活动会话注入快照。
1880
+ if (removed) await afterBindingsWrite(config)
1881
+ send(res, 200, { ok: true, removed, ...(await bindingsView(ctx)) })
1882
+ } catch (err) {
1883
+ send(res, err.httpStatus || 500, { ok: false, error: String(err.message || err) })
1884
+ }
1885
+ }
1886
+ })
1887
+ })
1888
+ }
1889
+
1132
1890
  // ==================== 斜杠命令 ====================
1133
1891
 
1134
1892
  // 宿主端无 locale 服务(CommandInvocation 不携带 UI 语言,settings 中亦无语言字段),
@@ -1383,7 +2141,7 @@ function registerCommands(ctx) {
1383
2141
  if (errors.nickname) {
1384
2142
  return { kind: 'error', text: t.failed(errors.nickname) }
1385
2143
  }
1386
- const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, nickname: patch.nickname }))
2144
+ const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, nickname: patch.nickname }), { source: 'command', action: 'nickname' })
1387
2145
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1388
2146
  return { kind: 'success', text: t.nicknameDone(patch.nickname) }
1389
2147
  }
@@ -1427,7 +2185,7 @@ function registerCommands(ctx) {
1427
2185
  if (first === 'reset') {
1428
2186
  // 重置活动配置,但保留人设预设库。
1429
2187
  // 经由逃生口:配置损坏时这是唯一还能落盘的操作(先备份损坏文件)。
1430
- const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape()
2188
+ const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape({ source: 'command', action: 'reset' })
1431
2189
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1432
2190
  return backupPath
1433
2191
  ? { kind: 'success', text: t.resetRecovered(backupPath) }
@@ -1436,13 +2194,13 @@ function registerCommands(ctx) {
1436
2194
 
1437
2195
  if (first === 'enable') {
1438
2196
  // 写队列内「读—改—写」,不在队列外改写内存缓存对象;无变化时跳过注入
1439
- const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: true }))
2197
+ const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: true }), { source: 'command', action: 'enable' })
1440
2198
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1441
2199
  return { kind: 'success', text: t.enableDone }
1442
2200
  }
1443
2201
 
1444
2202
  if (first === 'disable') {
1445
- const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: false }))
2203
+ const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: false }), { source: 'command', action: 'disable' })
1446
2204
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1447
2205
  return { kind: 'success', text: t.disableDone }
1448
2206
  }
@@ -1457,7 +2215,7 @@ function registerCommands(ctx) {
1457
2215
  const personas = clonePersonas(current.personas)
1458
2216
  personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
1459
2217
  return { ...current, personas }
1460
- })
2218
+ }, { source: 'command', action: 'savePreset' })
1461
2219
  return { kind: 'success', text: t.saveDone(personaName) }
1462
2220
  }
1463
2221
 
@@ -1521,7 +2279,7 @@ function registerCommands(ctx) {
1521
2279
  const personas = clonePersonas(current.personas)
1522
2280
  delete personas[personaName]
1523
2281
  return { ...current, personas }
1524
- })
2282
+ }, { source: 'command', action: 'deletePreset' })
1525
2283
  return { kind: 'success', text: t.delDone(personaName) }
1526
2284
  }
1527
2285
 
@@ -1555,7 +2313,7 @@ function registerCommands(ctx) {
1555
2313
  if (Object.keys(patch).length === 0) {
1556
2314
  return { kind: 'error', text: t.failed(droppedKeys.length > 0 ? `${t.unknownField}: ${droppedKeys.join(', ')}` : t.setUsage) }
1557
2315
  }
1558
- const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
2316
+ const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }), { source: 'command', action: 'set' })
1559
2317
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1560
2318
  return { kind: 'success', text: changed.length > 0 ? t.setDone(changed) : t.setNoChanges }
1561
2319
  }
@@ -1564,7 +2322,7 @@ function registerCommands(ctx) {
1564
2322
  if (!pendingPersonaProposal) return { kind: 'error', text: t.noPending }
1565
2323
  const proposal = pendingPersonaProposal
1566
2324
  pendingPersonaProposal = null
1567
- const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...proposal.patch }))
2325
+ const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...proposal.patch }), { source: 'command', action: 'confirm' })
1568
2326
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
1569
2327
  const detail = Object.keys(proposal.patch).map((key) => `${key}=${JSON.stringify(proposal.patch[key])}`).join(', ')
1570
2328
  return { kind: 'success', text: t.confirmDone(detail) }
@@ -1619,7 +2377,17 @@ function registerSystemPrompt(ctx) {
1619
2377
  // 代价为 0:我们不使用任何宿主提示词变量(需要上下文时,text 本身就是
1620
2378
  // provider)。关掉它同时让「预览所见」与「真实注入」逐字符一致。
1621
2379
  interpolate: false,
1622
- text: () => compilePrompt(configCache || DEFAULT_CONFIG)
2380
+ text: (assembly) => {
2381
+ // ⚠️ 必须同步:宿主以 `section.text(context)` 的形式**同步**调用
2382
+ // (dsh-system-prompt 的装配:`typeof entry.text === 'function' ? entry.text(context) : entry.text`),
2383
+ // 返回 Promise 会被当成 section 文本对象塞进提示词。所以这里只做纯字符串
2384
+ // 比较——路径的身份解析(realpath)已在 loadBindings() 里对**键**做过一次。
2385
+ //
2386
+ // `assembly.agent` 在诊断装配时缺席(宿主注释写明 `absent on diagnostics`),
2387
+ // cwdOfAgent 对 undefined 返回 null ⇒ configForCwd 原样返回全局配置。
2388
+ const base = configCache || DEFAULT_CONFIG
2389
+ return compilePrompt(configForCwd(base, cwdOfAgent(assembly && assembly.agent)).config)
2390
+ }
1623
2391
  })
1624
2392
 
1625
2393
  deliveryState.sectionRegisteredAt = new Date().toISOString()
@@ -1930,7 +2698,7 @@ function registerTools(ctx) {
1930
2698
  next[key] = patch[key]
1931
2699
  }
1932
2700
  return next
1933
- })
2701
+ }, { source: 'tool', action: 'setPersona' })
1934
2702
 
1935
2703
  if (changed.length === 0) {
1936
2704
  return { ok: true, message: promptTextOf(updated).toolNoChanges }
@@ -1958,6 +2726,13 @@ function registerTools(ctx) {
1958
2726
  export async function apply(ctx) {
1959
2727
  // 加载配置
1960
2728
  await loadConfig()
2729
+
2730
+ // 加载工作区绑定表。
2731
+ //
2732
+ // 必须在装配路径之外做完:system prompt section 的 text provider 是**同步**的
2733
+ // (见 registerSystemPrompt 里的说明),不能在里面 await 读盘。这里读一次,
2734
+ // 之后每轮装配只做纯字符串比较。失败不阻塞启动(绑定是可选增强)。
2735
+ await ensureBindings()
1961
2736
 
1962
2737
  // 提供 soulConfig 服务
1963
2738
  ctx.provide('soulConfig', {
@@ -1970,7 +2745,7 @@ export async function apply(ctx) {
1970
2745
  if (Object.keys(errors).length > 0) {
1971
2746
  throw new Error('配置校验失败:' + formatFieldErrors(errors))
1972
2747
  }
1973
- const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
2748
+ const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }), { source: 'service', action: 'update' })
1974
2749
  // 送达与另外三条写路径保持一致:服务调用方(其它插件)改配置后同样应当
1975
2750
  // 立即反映到活动会话,否则「改了配置不生效」会从服务这条路再次出现。
1976
2751
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
@@ -1982,7 +2757,7 @@ export async function apply(ctx) {
1982
2757
  },
1983
2758
  async resetConfig() {
1984
2759
  // 与 HTTP / 斜杠命令两条重置入口共用逃生口
1985
- const { config, promptChanged } = await resetConfigWithEscape()
2760
+ const { config, promptChanged } = await resetConfigWithEscape({ source: 'service', action: 'reset' })
1986
2761
  if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
1987
2762
  return config
1988
2763
  }
@@ -1991,6 +2766,8 @@ export async function apply(ctx) {
1991
2766
  // 注册各模块
1992
2767
  registerRoutes(ctx)
1993
2768
  registerPersonaRoutes(ctx)
2769
+ registerHistoryRoutes(ctx)
2770
+ registerBindingRoutes(ctx)
1994
2771
  registerCommands(ctx)
1995
2772
  registerSystemPrompt(ctx)
1996
2773
  registerTools(ctx)