dsh-soul 0.7.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/README_EN.md +39 -2
- package/client/index.mjs +3497 -99
- package/index.mjs +1966 -121
- package/lib/bindings.mjs +430 -0
- package/lib/config.mjs +82 -1
- package/lib/history.mjs +605 -0
- package/lib/personas.mjs +59 -0
- package/lib/session-scope.mjs +432 -0
- package/lib/workspace-scope.mjs +348 -0
- package/package.json +9 -4
- package/scripts/verify-bindings.mjs +552 -0
- package/scripts/verify-client.mjs +2209 -16
- package/scripts/verify-config.mjs +1929 -24
- package/scripts/verify-e2e-prompt.mjs +1186 -24
- package/scripts/verify-history.mjs +782 -0
- package/scripts/verify-scope.mjs +328 -0
- package/scripts/verify-session-scope.mjs +435 -0
package/index.mjs
CHANGED
|
@@ -29,13 +29,71 @@ 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'
|
|
50
|
+
import {
|
|
51
|
+
MAX_SESSION_ID_LENGTH,
|
|
52
|
+
ORPHAN_GRACE_MS,
|
|
53
|
+
SESSION_DIR_NAME,
|
|
54
|
+
SESSION_ENVELOPE_ONLY_FIELDS,
|
|
55
|
+
SESSION_OVERRIDE_FIELDS,
|
|
56
|
+
SESSION_SCOPE_VERSION,
|
|
57
|
+
createSessionScopeDoc,
|
|
58
|
+
decideSessionScopeFate,
|
|
59
|
+
isSessionId,
|
|
60
|
+
listSessionScopes,
|
|
61
|
+
quarantineSessionScope,
|
|
62
|
+
readSessionScope,
|
|
63
|
+
sanitizeSessionOverride,
|
|
64
|
+
sessionScopeDirFor,
|
|
65
|
+
sessionScopePathFor,
|
|
66
|
+
writeSessionScope
|
|
67
|
+
} from './lib/session-scope.mjs'
|
|
68
|
+
import {
|
|
69
|
+
MAX_WORKSPACE_SCOPES,
|
|
70
|
+
WORKSPACE_PERSONA_REF,
|
|
71
|
+
WORKSPACE_SCOPE_VERSION,
|
|
72
|
+
mergeWorkspaceOverride,
|
|
73
|
+
resolveWorkspaceOverride,
|
|
74
|
+
sanitizeWorkspaceScopes,
|
|
75
|
+
validateWorkspaceBinding
|
|
76
|
+
} from './lib/workspace-scope.mjs'
|
|
36
77
|
import { createInjectionSource } from './lib/injection.mjs'
|
|
37
|
-
import {
|
|
78
|
+
import {
|
|
79
|
+
findDuplicatePersonas, isBuiltinPersona, builtinPersonaNames, mergePersonas, personaMatches, resolvePersona, declaredPersonaKeys } from './lib/personas.mjs'
|
|
38
80
|
import { configWriteRefusal, describeConfigFailure, moveAsideConfigFile, readConfigFile, writeConfigFile } from './lib/store.mjs'
|
|
81
|
+
import {
|
|
82
|
+
buildHistoryEntry,
|
|
83
|
+
clearHistoryFile,
|
|
84
|
+
createEntryId,
|
|
85
|
+
diffFields,
|
|
86
|
+
diffPersonas,
|
|
87
|
+
findEntryIndex,
|
|
88
|
+
hashConfig,
|
|
89
|
+
historyPathFor,
|
|
90
|
+
MAX_READ_LINES,
|
|
91
|
+
planRollback,
|
|
92
|
+
randomSuffix,
|
|
93
|
+
readHistoryFile,
|
|
94
|
+
recordHistoryEntry,
|
|
95
|
+
toHistoryResponse
|
|
96
|
+
} from './lib/history.mjs'
|
|
39
97
|
|
|
40
98
|
const name = 'soul'
|
|
41
99
|
|
|
@@ -44,6 +102,19 @@ function configPath() {
|
|
|
44
102
|
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'soul-config.json')
|
|
45
103
|
}
|
|
46
104
|
|
|
105
|
+
// 变更记录的落盘路径:配置文件的**兄弟文件**(soul-config.json → soul-config.history.jsonl)。
|
|
106
|
+
// 与配置同目录而不塞进配置里,理由见 docs/DESIGN-audit-rollback.md §4.1。
|
|
107
|
+
function historyPath() {
|
|
108
|
+
return historyPathFor(configPath())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 最近一次「变更记录没能记下来」的原因(记成功则清空)。
|
|
112
|
+
//
|
|
113
|
+
// 审计失败绝不能连带配置写失败 —— 那会把「记不下来」升级成「改不了」,
|
|
114
|
+
// 而配置写入是这个插件的核心功能。但也不能装作没发生:原因经
|
|
115
|
+
// GET /api/soul/history 的 warning 字段回传,设置页会原样显示。
|
|
116
|
+
let lastHistoryFailure = null
|
|
117
|
+
|
|
47
118
|
// 版本号:唯一来源是 package.json。
|
|
48
119
|
//
|
|
49
120
|
// 刻意**不**在这里再写一个常量 —— 需要手工同步的版本号已经有三处
|
|
@@ -311,6 +382,36 @@ const MAX_BODY_BYTES = 64 * 1024
|
|
|
311
382
|
// 内存中的配置缓存
|
|
312
383
|
let configCache = null
|
|
313
384
|
|
|
385
|
+
// 配置的**单调修订号**:每次配置真的被替换(读盘成功、写入成功、重置)+1。
|
|
386
|
+
//
|
|
387
|
+
// 为什么需要它:system prompt 的 section.text 是宿主**同步**调用的,每次装配都读
|
|
388
|
+
// `configCache` —— 而 configCache 由写队列整体替换。若一次装配过程中间发生了写入,
|
|
389
|
+
// 同一轮里 section 多次求值时可能拿到**两个不同版本**的配置,症状正是「System Prompt
|
|
390
|
+
// Updated 已出现、模型却仍用旧昵称」这一类难以复现的问题。
|
|
391
|
+
//
|
|
392
|
+
// 有了修订号,装配侧可以:
|
|
393
|
+
// ① 先取一次快照再求值(见 snapshotConfig),保证一轮只用一份配置;
|
|
394
|
+
// ② 把「这一轮实际用了哪一版」记进诊断状态,事后能回答「进模型的是哪一版」。
|
|
395
|
+
//
|
|
396
|
+
// 刻意**不**做「装配期间禁止写入」:装配是纯读,写入走队列,加锁只会让保存变慢。
|
|
397
|
+
// 单调号足以定位,不需要互斥。
|
|
398
|
+
let configRevision = 0
|
|
399
|
+
|
|
400
|
+
function bumpConfigRevision() {
|
|
401
|
+
configRevision += 1
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 取当前配置的不可变快照。装配路径上**只调用一次**,后续全部基于它。
|
|
405
|
+
//
|
|
406
|
+
// 返回的 revision 与 value 是同一时刻的配对:先读 revision 再读 value(或反过来)
|
|
407
|
+
// 在单线程里等价,因为两者之间没有 await —— 一旦有 await,就又会读到两个版本。
|
|
408
|
+
function snapshotConfig() {
|
|
409
|
+
return {
|
|
410
|
+
revision: configRevision,
|
|
411
|
+
value: configCache || DEFAULT_CONFIG
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
314
415
|
// 磁盘上的配置存在但不可用(读不出来 / 不是合法 JSON)时的记录。
|
|
315
416
|
// 置位后 saveConfig 会拒绝写入,避免把损坏的配置静默清空成默认值。
|
|
316
417
|
let configLoadFailure = null
|
|
@@ -336,6 +437,7 @@ async function loadConfig() {
|
|
|
336
437
|
}
|
|
337
438
|
|
|
338
439
|
configCache = exists ? migrateConfig(value) : { ...DEFAULT_CONFIG }
|
|
440
|
+
bumpConfigRevision()
|
|
339
441
|
return configCache
|
|
340
442
|
}
|
|
341
443
|
|
|
@@ -354,6 +456,7 @@ async function saveConfig(config) {
|
|
|
354
456
|
delete clean.examples
|
|
355
457
|
await writeConfigFile(configPath(), clean)
|
|
356
458
|
configCache = clean
|
|
459
|
+
bumpConfigRevision()
|
|
357
460
|
return clean
|
|
358
461
|
}
|
|
359
462
|
|
|
@@ -371,26 +474,115 @@ function enqueueConfigWrite(task) {
|
|
|
371
474
|
return run
|
|
372
475
|
}
|
|
373
476
|
|
|
374
|
-
//
|
|
477
|
+
// 不影响提示词的字段:变更它们不需要刷新系统提示词,也不需要向活动会话注入快照;
|
|
375
478
|
// 但它们仍是实际配置变更,必须计入 changed(供调用方判断「已保存 / 无变化」并驱动前端 dirty)。
|
|
376
|
-
|
|
479
|
+
//
|
|
480
|
+
// 由 DEFAULT_CONFIG 减去 PROMPT_INPUT_FIELDS **推导**,而不是手写清单。
|
|
481
|
+
// 手写清单的代价已经发生过两次:trail* 四项之外,requireToolConfirmation 与
|
|
482
|
+
// historyMaxEntries 都曾被漏在外面 —— 后果是改一次「保留条数」会重新注册 section
|
|
483
|
+
// 并向所有活动会话注入一条配置更新消息,用户看到的是「我只调了个容量,Agent 却被
|
|
484
|
+
// 通知了一遍配置变了」。这类「漏登记」不会报错,只会安静地多注入。
|
|
485
|
+
//
|
|
486
|
+
// 推导的安全前提是 compilePrompt 只读 PROMPT_INPUT_FIELDS 里的字段,该前提由
|
|
487
|
+
// scripts/verify-config.mjs 的「编译源码读取的每个 config.X 都必须已登记」钉住。
|
|
488
|
+
//
|
|
489
|
+
// 未知键(不在 DEFAULT_CONFIG 里)会落在「影响提示词」一侧 —— 方向是安全的:
|
|
490
|
+
// 最坏是多重启一次提示词,而不是改了不生效。
|
|
491
|
+
const NON_PROMPT_FIELDS = new Set(
|
|
492
|
+
Object.keys(DEFAULT_CONFIG).filter((key) => !PROMPT_INPUT_FIELDS.includes(key))
|
|
493
|
+
)
|
|
377
494
|
|
|
378
495
|
// 在写队列中执行一次配置更新:mutate 收到当前配置的浅拷贝,返回新配置对象。
|
|
379
496
|
// 注意 mutate 必须在队列任务内完成全部读取与合并,不要在队列外提前读取配置。
|
|
380
497
|
// 返回 { config, changed, promptChanged }:
|
|
381
498
|
// - changed:实际发生变化的字段名数组(不含 personas 预设库),供调用方判断有无变更
|
|
382
499
|
// - promptChanged:changed 中会影响 Agent 行为的子集;为空时应跳过提示词刷新与会话注入
|
|
383
|
-
|
|
500
|
+
//
|
|
501
|
+
// 第二参数 source 是**变更记录的归属**,形如 { source, action }:source 是渠道
|
|
502
|
+
// (web / command / tool / service / persona / rollback / restorePersona),
|
|
503
|
+
// action 是动作(save / set / use / …)。两个都是白名单枚举,写进条目与读回条目时各校验一次。
|
|
504
|
+
//
|
|
505
|
+
// 为什么必须由调用方显式给出:同一次「改了 style」可能是网页保存、斜杠命令、Agent 工具
|
|
506
|
+
// 或其它插件调用,事后从配置差异里是**推不出来**的。审计的价值有一半在于回答
|
|
507
|
+
// 「是谁改的」—— 默认值会让这半个价值静默消失,所以这里刻意不给默认值。
|
|
508
|
+
async function commitConfig(mutate, source) {
|
|
384
509
|
return enqueueConfigWrite(async () => {
|
|
385
510
|
const current = await loadConfig()
|
|
386
|
-
|
|
511
|
+
// mutate 允许返回 Promise:撤销要在「读记录 → 规划 → 写配置」之间保持原子,
|
|
512
|
+
// 而记录必须在**队列内**读。放在队列外读会有一个窗口——两次撤销并发时,
|
|
513
|
+
// 后者的规划建立在已被前者改过的配置上,指纹校验会给出看起来像「数据损坏」的
|
|
514
|
+
// diverged,排查方向完全跑偏。
|
|
515
|
+
const next = await mutate({ ...current })
|
|
387
516
|
const changed = diffKeys(current, next)
|
|
388
517
|
const promptChanged = changed.filter((key) => !NON_PROMPT_FIELDS.has(key))
|
|
389
518
|
const config = await saveConfig(next)
|
|
390
|
-
|
|
519
|
+
|
|
520
|
+
// 差分必须在 saveConfig **之后**、且以它返回的配置为基准:saveConfig 会剔除
|
|
521
|
+
// tone / presets / examples 三个废弃字段,拿 mutate 的产物去比会把它们误报成变更。
|
|
522
|
+
const entry = await auditCommit(current, config, source, config.historyMaxEntries)
|
|
523
|
+
|
|
524
|
+
return { config, changed, promptChanged, entry }
|
|
391
525
|
})
|
|
392
526
|
}
|
|
393
527
|
|
|
528
|
+
// 路由内主动拒绝:从队列任务里抛出即可中止本次提交(不写盘),由 handler 按状态码回话。
|
|
529
|
+
// 不用返回一个「什么都没改」的配置来收场 —— 那会照样重写一次配置文件,
|
|
530
|
+
// 让一次失败的操作在磁盘上留下痕迹。
|
|
531
|
+
function httpError(status, message) {
|
|
532
|
+
const err = new Error(message)
|
|
533
|
+
err.httpStatus = status
|
|
534
|
+
return err
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 把一次成功的提交记进变更记录。
|
|
538
|
+
//
|
|
539
|
+
// 时机与失败处理(docs/DESIGN-audit-rollback.md §5):调用点已在 saveConfig 之后,
|
|
540
|
+
// 所以「配置写失败却留下记录」在结构上不可能。反过来「配置已写、记录没写成」是可能的
|
|
541
|
+
// (磁盘只读、记录文件被占用)—— 此时把原因记进 lastHistoryFailure 并**继续返回成功**,
|
|
542
|
+
// 因为配置确实已经改了。
|
|
543
|
+
async function auditCommit(current, config, source, maxEntries) {
|
|
544
|
+
try {
|
|
545
|
+
const fields = diffFields(current, config)
|
|
546
|
+
const personas = diffPersonas(current.personas, config.personas)
|
|
547
|
+
|
|
548
|
+
// 「什么都没变」必须在构造条目**之前**判掉,不能靠 buildHistoryEntry 返回 null 来表达。
|
|
549
|
+
// 后者把两种情况混成了同一个返回值:真的没变,和条目因为 shape 不对被拒
|
|
550
|
+
// (例如某个调用点漏标 source)—— 后者是代码缺陷,混进来就会变成
|
|
551
|
+
// 「配置改了、记录里没有、也没有任何告警」。这条判据把缺陷从静默变响亮。
|
|
552
|
+
const changedAny =
|
|
553
|
+
Object.keys(fields.after).length > 0 ||
|
|
554
|
+
personas.added.length > 0 || personas.updated.length > 0 || personas.removed.length > 0
|
|
555
|
+
if (!changedAny) {
|
|
556
|
+
lastHistoryFailure = null
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const entry = buildHistoryEntry({
|
|
561
|
+
id: createEntryId(Date.now(), randomSuffix()),
|
|
562
|
+
at: new Date().toISOString(),
|
|
563
|
+
source: source && source.source,
|
|
564
|
+
action: source && source.action,
|
|
565
|
+
label: source && source.label,
|
|
566
|
+
configHash: hashConfig(config),
|
|
567
|
+
before: fields.before,
|
|
568
|
+
after: fields.after,
|
|
569
|
+
personas
|
|
570
|
+
})
|
|
571
|
+
if (!entry) {
|
|
572
|
+
// 有变化却构造不出条目 ⇒ 一定是调用点的 source/action 没标或标错。
|
|
573
|
+
// 报出来而不是当作「无变化」跳过:审计缺一条而没人知道,比留一条错的更糟。
|
|
574
|
+
lastHistoryFailure = `变更记录条目构造失败(source=${JSON.stringify(source)}),本次改动未被记录`
|
|
575
|
+
return null
|
|
576
|
+
}
|
|
577
|
+
await recordHistoryEntry(historyPath(), entry, maxEntries)
|
|
578
|
+
lastHistoryFailure = null
|
|
579
|
+
return entry
|
|
580
|
+
} catch (err) {
|
|
581
|
+
lastHistoryFailure = String((err && err.message) || err)
|
|
582
|
+
return null
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
394
586
|
// 比较两份配置的差异字段名(浅比较;soul 配置为扁平对象)。
|
|
395
587
|
// personas(人设预设库)不属于活动配置:库的增删改不应触发提示词刷新与会话注入。
|
|
396
588
|
function diffKeys(a, b) {
|
|
@@ -420,13 +612,16 @@ function defaultConfigPreservingPersonas(current) {
|
|
|
420
612
|
//
|
|
421
613
|
// 复用 commitConfig 的「读—改—写」,不另写一份;写入失败时恢复失败标记,
|
|
422
614
|
// 避免把「文件仍然不可用」误报成正常状态。
|
|
423
|
-
|
|
615
|
+
//
|
|
616
|
+
// source 由调用方给(web 重置 / 斜杠命令重置 / 其它插件调用),穿透给 commitConfig
|
|
617
|
+
// 用于变更记录 —— 三个入口都是「重置」,但来源不同,记录里要分得开。
|
|
618
|
+
async function resetConfigWithEscape(source) {
|
|
424
619
|
const failure = configLoadFailure
|
|
425
620
|
const backupPath = failure ? await moveAsideConfigFile(configPath()) : null
|
|
426
621
|
if (failure) configLoadFailure = null
|
|
427
622
|
|
|
428
623
|
try {
|
|
429
|
-
const result = await commitConfig((current) => defaultConfigPreservingPersonas(current))
|
|
624
|
+
const result = await commitConfig((current) => defaultConfigPreservingPersonas(current), source)
|
|
430
625
|
return { ...result, backupPath }
|
|
431
626
|
} catch (err) {
|
|
432
627
|
if (failure) configLoadFailure = failure
|
|
@@ -522,7 +717,9 @@ const deliveryState = {
|
|
|
522
717
|
delivered: 0, // 最近一次成功注入的会话数
|
|
523
718
|
failed: 0, // 最近一次注入抛错的会话数
|
|
524
719
|
injectionProblem: null, // 未能如实送达的原因;正常为 null
|
|
525
|
-
sectionRegisteredAt: null // section 最近一次注册成功的时间(ISO)
|
|
720
|
+
sectionRegisteredAt: null, // section 最近一次注册成功的时间(ISO)
|
|
721
|
+
lastAssembledRevision: null, // 最近一次装配实际使用的配置修订号(排查「改了没生效」用)
|
|
722
|
+
lastAssembledScope: null // 最近一次装配用的是哪一层:'workspace' | 'cwd'
|
|
526
723
|
}
|
|
527
724
|
|
|
528
725
|
// 同一原因只告一次警,避免每次保存都刷屏;一旦恢复正常则清掉记录,
|
|
@@ -587,14 +784,23 @@ function injectPromptToAllAgents(ctx, config) {
|
|
|
587
784
|
}
|
|
588
785
|
|
|
589
786
|
const T = promptTextOf(config)
|
|
590
|
-
const prompt = compilePrompt(config)
|
|
591
|
-
const snapshotText = prompt || T.injectDisabled
|
|
592
787
|
|
|
593
788
|
let delivered = 0
|
|
594
789
|
let failed = 0
|
|
595
790
|
let lastError = null
|
|
596
791
|
|
|
597
792
|
for (const agent of listed) {
|
|
793
|
+
// **每个会话各自编译**,不能共用一份文本。
|
|
794
|
+
//
|
|
795
|
+
// 工作区绑定让「配置」不再是全局唯一的:A 项目会话该拿到「资深架构师」,B 项目会话
|
|
796
|
+
// 该拿到「极简主义者」。若在这里算一份全局文本发给所有人,B 项目会收到 A 的人设 ——
|
|
797
|
+
// 这比不注入更糟(不注入时它至少还是全局配置,与用户以为的一致)。
|
|
798
|
+
//
|
|
799
|
+
// 拿不到 cwd(会话头无该字段)时 configForCwd 返回原配置,行为与绑定功能上线前一致。
|
|
800
|
+
const perAgent = configForCwd(config, cwdOfAgent(agent)).config
|
|
801
|
+
const prompt = compilePrompt(perAgent)
|
|
802
|
+
const snapshotText = prompt || T.injectDisabled
|
|
803
|
+
|
|
598
804
|
try {
|
|
599
805
|
agent.inject(createUserMessage({
|
|
600
806
|
content: [{
|
|
@@ -643,6 +849,381 @@ function refreshPromptAndInject(ctx, config) {
|
|
|
643
849
|
injectPromptToAllAgents(ctx, config)
|
|
644
850
|
}
|
|
645
851
|
|
|
852
|
+
// ==================== 工作区绑定:按目录解析人设 ====================
|
|
853
|
+
//
|
|
854
|
+
// 三层解析(docs/DESIGN-persona-scope.md §5):会话显式覆盖 → 目录绑定(最长前缀)→ 全局配置。
|
|
855
|
+
// 0.8.1-b 落地的是中间那层;会话覆盖(0.8.1-c/d)尚未接入,接入点见 resolveForCwd 的注释。
|
|
856
|
+
//
|
|
857
|
+
// 为什么绑定表要单独缓存而不是每次读盘:本节的两个消费者都在**每一轮装配**上 ——
|
|
858
|
+
// system prompt section 的 text provider 每轮都被调用一次,活动会话注入每次保存各一次。
|
|
859
|
+
// 读盘 + 200 次 realpath 放进这条路径是不可接受的,所以绑定表在加载时规范化一次。
|
|
860
|
+
|
|
861
|
+
// 绑定文件路径(configPath() 的兄弟文件)。
|
|
862
|
+
function bindingsPath() {
|
|
863
|
+
return bindingsPathFor(configPath())
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// 绑定表读数。null = 还没读过(与「读过了、确实是空表」必须分得开,
|
|
867
|
+
// 后者意味着「用户一条都没绑」,前者意味着「还没问过磁盘」)。
|
|
868
|
+
let bindingsCache = null
|
|
869
|
+
|
|
870
|
+
// 最近一次绑定加载失败 / 有坏条目的说明(无问题为 null)。
|
|
871
|
+
let bindingsLoadFailure = null
|
|
872
|
+
|
|
873
|
+
// 命中判定的比较表。
|
|
874
|
+
//
|
|
875
|
+
// 这里有两个键集合,都指向同一条绑定:
|
|
876
|
+
// · **展示键**:绑定表里原样存着的形式,UI 与用户看到的就是它;
|
|
877
|
+
// · **身份键**:`realpath` 解析后的形式(解掉符号链接 / Junction;Windows 上还会
|
|
878
|
+
// 规范化盘符大小写)。
|
|
879
|
+
//
|
|
880
|
+
// 为什么要两个都进比较表 —— 两边各自可能缺席一次解析:
|
|
881
|
+
// · 会话 cwd 由 Node 的 `process.cwd()` 提供(`GetCurrentDirectory` 返回的是**规范**
|
|
882
|
+
// 路径),而绑定目录是用户在选择器里点出来的,很可能是 Junction 路径。
|
|
883
|
+
// 典型失配:cwd = `D:\真实目录`、绑定键 = `D:\junction入口`。
|
|
884
|
+
// · 反过来,用户手工粘贴的路径可能本就是规范路径,而会话 cwd 落在 Junction 里。
|
|
885
|
+
// 两个键都留着,任一侧成立即命中;`bindingsDisplayOf` 负责把命中结果翻译回展示键,
|
|
886
|
+
// 因为用户不认得 realpath 吐出来的那个名字。
|
|
887
|
+
//
|
|
888
|
+
// **不对 cwd 做 realpath**:text provider 必须是**同步**的(见 registerSystemPrompt),
|
|
889
|
+
// 而 realpath 是异步 I/O。把身份解析全部放在**键**侧,这个约束就消失了 ——
|
|
890
|
+
// 键在加载时算一次,之后每轮装配都只是字符串比较。
|
|
891
|
+
let bindingsCompareTable = null
|
|
892
|
+
|
|
893
|
+
// 身份键 → 展示键(仅当两者不同时才有一条)。
|
|
894
|
+
let bindingsDisplayOf = null
|
|
895
|
+
|
|
896
|
+
// 解析绑定表:读盘 + 为每条键算出身份形态。
|
|
897
|
+
//
|
|
898
|
+
// 失败时**以空表继续**并把原因记在 bindingsLoadFailure 上,绝不抛错:
|
|
899
|
+
// 绑定是可选增强,读不出来不该让整个插件的装配流程崩掉(与 loadConfig 的策略不同,
|
|
900
|
+
// 因为配置是用户唯一的数据副本,而绑定不是)。
|
|
901
|
+
//
|
|
902
|
+
// 失败时**不写 bindingsCache**,与 loadConfig 同款:用户把文件改对之后,下一次
|
|
903
|
+
// 加载(绑定路由写完表之后总会重新加载)就能自动恢复,不必重启 DSH。
|
|
904
|
+
// 此时比较表被清成空表 —— 宁可暂时不生效,也不能拿上一次的旧表继续命中。
|
|
905
|
+
async function loadBindings() {
|
|
906
|
+
const filePath = bindingsPath()
|
|
907
|
+
const { bindings, dropped, failure } = await readBindingsFile(filePath)
|
|
908
|
+
bindingsLoadFailure = failure || (dropped.length > 0 ? `${dropped.length} 条绑定因格式问题被忽略。` : null)
|
|
909
|
+
|
|
910
|
+
if (failure) {
|
|
911
|
+
bindingsCompareTable = {}
|
|
912
|
+
bindingsDisplayOf = new Map()
|
|
913
|
+
return { bindings: {}, dropped, failure, filePath }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// 计算身份键。并发发起(最多 MAX_BINDINGS=200 条,realpath 很轻),
|
|
917
|
+
// 但 resolveDirIdentity 自身绝不抛错,所以这里不需要 per-item 的 try/catch。
|
|
918
|
+
const compareTable = { ...bindings }
|
|
919
|
+
const displayOf = new Map()
|
|
920
|
+
const keys = Object.keys(bindings)
|
|
921
|
+
const identities = await Promise.all(keys.map(dir => resolveDirIdentity(dir)))
|
|
922
|
+
|
|
923
|
+
for (let i = 0; i < keys.length; i++) {
|
|
924
|
+
const dir = keys[i]
|
|
925
|
+
const identity = identities[i]
|
|
926
|
+
// 只有真的不同才加别键:同一条绑定占两个键位会让 shadowed 出现重复项。
|
|
927
|
+
if (!identity || compareKey(identity) === compareKey(dir)) continue
|
|
928
|
+
// 身份键撞上另一条展示键时**不覆盖** —— 展示键是用户亲手写的那个,
|
|
929
|
+
// 优先级更高;被挡住的身份键只是少一条别名,判定方向安全。
|
|
930
|
+
if (hasOwn(compareTable, identity)) continue
|
|
931
|
+
compareTable[identity] = bindings[dir]
|
|
932
|
+
displayOf.set(identity, dir)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
bindingsCompareTable = compareTable
|
|
936
|
+
bindingsDisplayOf = displayOf
|
|
937
|
+
bindingsCache = { bindings, dropped, failure: null, filePath }
|
|
938
|
+
return bindingsCache
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// 按目录解析该用什么人设(三层解析的「目录层」)。
|
|
942
|
+
//
|
|
943
|
+
// 返回 null 表示没有可用绑定 ⇒ 调用方回落全局配置(这是绝大多数用户的常态)。
|
|
944
|
+
// 返回对象时:
|
|
945
|
+
// · dir / persona —— 真正生效的那条绑定;
|
|
946
|
+
// · shadowed —— 命中了、但人设已不存在的更具体绑定(不生效,但必须能说出来)。
|
|
947
|
+
//
|
|
948
|
+
// 本函数是**同步**的:它每轮装配都被调用,且 text provider 不允许是异步的。
|
|
949
|
+
// 因此读盘只能发生在装配之外(apply() 启动时、绑定路由写完之后),见 ensureBindings。
|
|
950
|
+
async function ensureBindings() {
|
|
951
|
+
if (!bindingsCache) await loadBindings()
|
|
952
|
+
return bindingsCache
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function resolveBindingForCwd(cwd, userPersonas) {
|
|
956
|
+
if (!bindingsCompareTable || !bindingsDisplayOf) return null
|
|
957
|
+
const normalized = normalizeDirPath(cwd)
|
|
958
|
+
if (!normalized) return null
|
|
959
|
+
|
|
960
|
+
const resolved = resolvePersonaForCwd(normalized, bindingsCompareTable, userPersonas)
|
|
961
|
+
if (!resolved) return null
|
|
962
|
+
|
|
963
|
+
// 命中身份键时回报展示键(用户认得的是他自己写的那个名字)。
|
|
964
|
+
const display = dir => (bindingsDisplayOf.get(dir) || dir)
|
|
965
|
+
const seen = new Set()
|
|
966
|
+
const shadowed = []
|
|
967
|
+
for (const item of resolved.shadowed) {
|
|
968
|
+
const dir = display(item.dir)
|
|
969
|
+
// 同一条绑定的展示键与身份键可能都命中,去重后 UI 才不会把一条失效绑定画两遍。
|
|
970
|
+
if (seen.has(dir)) continue
|
|
971
|
+
seen.add(dir)
|
|
972
|
+
shadowed.push({ ...item, dir })
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return { dir: display(resolved.dir), persona: resolved.persona, shadowed }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ==================== 三级作用域:Global → Workspace → Session ====================
|
|
979
|
+
//
|
|
980
|
+
// 解析链(docs/DESIGN-config-scope.md §2.2):
|
|
981
|
+
//
|
|
982
|
+
// workspaceId(索引命中) → 工作区覆盖 → 部分覆盖全局
|
|
983
|
+
// ↓ 索引未命中(服务重启 / 新会话 / 索引空)
|
|
984
|
+
// cwd 前缀匹配(lib/bindings.mjs 既有能力) → 目录绑定 → 部分覆盖全局
|
|
985
|
+
// ↓ 都没有
|
|
986
|
+
// Global
|
|
987
|
+
//
|
|
988
|
+
// **失败方向必须安全**:任何一环不可用,只能在「更精确」与「更宽松」之间移动,
|
|
989
|
+
// 不能变成「错误的人设」。
|
|
990
|
+
|
|
991
|
+
// sessionId → workspaceId 的**只读快照**。
|
|
992
|
+
//
|
|
993
|
+
// 为什么是快照而不是"每次问宿主":`systemPrompt.section.text` 是宿主**同步**调用的,
|
|
994
|
+
// 里面不能有 await、也不能做线性扫描(每次装配 × 每个工作区 × 每个 sessionIds getter)。
|
|
995
|
+
// 所以索引在装配路径**之外**维护,装配时只查表。
|
|
996
|
+
//
|
|
997
|
+
// 键与值都用 String:宿主用品牌类型(WorkspaceId / SessionId),把它们当字符串比较
|
|
998
|
+
// 是唯一稳定做法(品牌类型在运行时就是字符串,但对象比较会因代理包装而失配)。
|
|
999
|
+
let sessionWorkspaceIndex = new Map()
|
|
1000
|
+
|
|
1001
|
+
// 索引最后一次成功重建的时间(ISO)。诊断用:排查「工作区绑定为什么没生效」时,
|
|
1002
|
+
// 第一个要看的就是索引有没有建起来。
|
|
1003
|
+
let workspaceIndexBuiltAt = null
|
|
1004
|
+
|
|
1005
|
+
// 重建索引。**同步且不做 I/O** —— workspaceRegistry.list() 是内存投影。
|
|
1006
|
+
//
|
|
1007
|
+
// `sessionIds` 是 getter,每次访问都会过滤一遍(宿主内部按 candidateMatches 校验),
|
|
1008
|
+
// 因此这里只遍历一次并把结果拍平成 Map,绝不把这个 getter 存下来反复问。
|
|
1009
|
+
function rebuildWorkspaceIndex(ctx) {
|
|
1010
|
+
const registry = ctx.get('workspaceRegistry')
|
|
1011
|
+
if (!registry || typeof registry.list !== 'function') {
|
|
1012
|
+
// 服务缺席不是错误:没有它就只能走 cwd 回退,而那正是 0.8.1 的语义。
|
|
1013
|
+
return false
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
let listed
|
|
1017
|
+
try {
|
|
1018
|
+
listed = registry.list()
|
|
1019
|
+
} catch {
|
|
1020
|
+
return false
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const next = new Map()
|
|
1024
|
+
for (const workspace of Array.isArray(listed) ? listed : []) {
|
|
1025
|
+
const id = workspace && workspace.id !== undefined ? String(workspace.id) : ''
|
|
1026
|
+
if (id === '') continue
|
|
1027
|
+
let sessionIds
|
|
1028
|
+
try {
|
|
1029
|
+
sessionIds = workspace.sessionIds
|
|
1030
|
+
} catch {
|
|
1031
|
+
// 单个工作区的 getter 抛错不该让整张索引失败(宿主注释:候选会话可能已被移出)
|
|
1032
|
+
continue
|
|
1033
|
+
}
|
|
1034
|
+
for (const sessionId of Array.isArray(sessionIds) ? sessionIds : []) {
|
|
1035
|
+
next.set(String(sessionId), id)
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
sessionWorkspaceIndex = next
|
|
1040
|
+
workspaceIndexBuiltAt = new Date().toISOString()
|
|
1041
|
+
return true
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// 从装配上下文里取 sessionId。
|
|
1045
|
+
//
|
|
1046
|
+
// `agent.id` **就是** sessionId —— 宿主注册 typert lookup 时用的正是
|
|
1047
|
+
// `wireTypeSymbol: "@deepseek-ai/dsh-session/types#SessionId"`,
|
|
1048
|
+
// `resolve: (sessionId) => this.get(sessionId)`。
|
|
1049
|
+
//
|
|
1050
|
+
// 诊断装配时 agent 缺席,返回 null。
|
|
1051
|
+
function sessionIdOfAgent(agent) {
|
|
1052
|
+
const id = agent?.id
|
|
1053
|
+
if (typeof id === 'string' && id.trim() !== '') return id
|
|
1054
|
+
if (typeof id === 'number') return String(id)
|
|
1055
|
+
return null
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// 把「全局配置 + 一次作用域解析结果」合成为该会话实际生效的配置。
|
|
1059
|
+
//
|
|
1060
|
+
// 三层顺序固定:先工作区(或目录),再会话覆盖。后者能盖前者 —— 这正是
|
|
1061
|
+
// 「本次会话临时改用教学模式」的语义。
|
|
1062
|
+
//
|
|
1063
|
+
// 用预设的**部分覆盖**语义(与 /api/soul/personas/use 同一条规则):只覆盖条目实际
|
|
1064
|
+
// 声明过的字段,未声明的保持上层值。这条复用不是巧合 —— 用户在设置页点「使用」
|
|
1065
|
+
// 一个预设时看到的效果,必须与他在该目录里自动拿到的效果**逐字段一致**,
|
|
1066
|
+
// 否则「绑定生效了吗」将无法用任何已有操作来验证。
|
|
1067
|
+
//
|
|
1068
|
+
// `enabled` 刻意**不在**任何覆盖范围内:全局总开关始终说话。
|
|
1069
|
+
//
|
|
1070
|
+
// `sessionOverride` 由调用方从**内存缓存**取(见 sessionOverrides)。这里不读盘:
|
|
1071
|
+
// 装配是同步的,读盘会把 Promise 塞进提示词。
|
|
1072
|
+
function configForScope(base, cwd, workspaceId, sessionOverride) {
|
|
1073
|
+
// ① 工作区层:workspaceId 优先,cwd 回退。
|
|
1074
|
+
const scopes = workspaceScopesOf(base)
|
|
1075
|
+
const workspaceOverride = workspaceId ? resolveWorkspaceOverride(workspaceId, scopes) : null
|
|
1076
|
+
let merged = base
|
|
1077
|
+
let source = 'global'
|
|
1078
|
+
let binding = null
|
|
1079
|
+
|
|
1080
|
+
if (workspaceOverride) {
|
|
1081
|
+
merged = mergeWorkspaceOverride(base, workspaceOverride)
|
|
1082
|
+
source = 'workspace'
|
|
1083
|
+
} else {
|
|
1084
|
+
// ② 目录回退(0.8.1 语义):索引没命中时,目录仍是可信信息。
|
|
1085
|
+
const resolved = resolveBindingForCwd(cwd, base.personas)
|
|
1086
|
+
if (resolved) {
|
|
1087
|
+
const persona = resolvePersona(resolved.persona, base.personas)
|
|
1088
|
+
if (persona) {
|
|
1089
|
+
const { patch } = sanitizeConfig(pickPersonaValues(persona))
|
|
1090
|
+
merged = { ...base, ...patch }
|
|
1091
|
+
source = 'directory'
|
|
1092
|
+
binding = resolved
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ③ 会话层:最贴近用户当下意图,优先级最高。
|
|
1098
|
+
// 它盖的是**字段**,不是整份配置 —— 只改 style 的会话覆盖,其余字段仍来自上层。
|
|
1099
|
+
if (sessionOverride) {
|
|
1100
|
+
merged = mergeSessionOverride(merged, sessionOverride)
|
|
1101
|
+
source = 'session'
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return { config: merged, source, workspaceId: workspaceId || null, binding }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// 兼容既有调用点:只按 cwd 解析(不含工作区层)。
|
|
1108
|
+
// 保留这个名字是因为 verify-*.mjs 与若干注释按它描述契约;新代码应直接用 configForScope。
|
|
1109
|
+
function configForCwd(base, cwd) {
|
|
1110
|
+
const { config, binding } = configForScope(base, cwd, null, null)
|
|
1111
|
+
return { config, binding }
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 读出配置里的工作区覆盖表(已校验过的形态)。
|
|
1115
|
+
function workspaceScopesOf(config) {
|
|
1116
|
+
const raw = config && config.workspaces
|
|
1117
|
+
if (!raw || typeof raw !== 'object') return {}
|
|
1118
|
+
const { scopes } = sanitizeWorkspaceScopes(raw)
|
|
1119
|
+
return scopes
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ==================== 会话级覆盖(0.9.0-c)====================
|
|
1123
|
+
//
|
|
1124
|
+
// 存储:`$DSH_HOME/soul-sessions/{sha256(sessionId)[:32]}.json`(与 soul-config.json 同级)。
|
|
1125
|
+
// 校验与回收状态机在 lib/session-scope.mjs;这里只负责**内存缓存**与合并。
|
|
1126
|
+
//
|
|
1127
|
+
// 为什么必须有缓存:装配路径是同步的(宿主 `section.text(context)` 同步调用),
|
|
1128
|
+
// 不能在里面 await 一次读盘。所以:
|
|
1129
|
+
// · 缓存按需加载(首次遇到某个 sessionId 时排一个异步读盘);
|
|
1130
|
+
// · 读盘完成后写回缓存,下一轮装配即可命中;
|
|
1131
|
+
// · 写入(用户改覆盖)时同步更新缓存,**不等**磁盘 —— 让「改完立刻生效」成立。
|
|
1132
|
+
//
|
|
1133
|
+
// 失效方向是安全的:缓存未命中 ⇒ 这一轮按上层配置(工作区/全局)走,
|
|
1134
|
+
// 最坏是"第一次生效慢一拍",不会给出错误的人设。
|
|
1135
|
+
const sessionOverrides = new Map() // sessionId → override(已校验)
|
|
1136
|
+
const sessionOverridesInFlight = new Set()
|
|
1137
|
+
// 读过的 sessionId(含"读过但确实没有覆盖"):避免每轮都重新排一次读盘
|
|
1138
|
+
const sessionOverridesKnown = new Set()
|
|
1139
|
+
// sessionId → 用户实际点选的预设名(sidecar 里的 personaName)。
|
|
1140
|
+
//
|
|
1141
|
+
// 与 override 分开存:override 是**模型用的字段**,personaName 是**给人看的注解**。
|
|
1142
|
+
// 两者分开的好处是 —— 字段被手工改过、或多个预设字段相同时,
|
|
1143
|
+
// 界面仍能显示"你点的确实是这个",而不是靠字段反查猜一个。
|
|
1144
|
+
const sessionPersonaNames = new Map()
|
|
1145
|
+
|
|
1146
|
+
// 读出某个会话的覆盖(同步,只查缓存)。
|
|
1147
|
+
//
|
|
1148
|
+
// 未命中时排一次异步加载并返回 null —— 本轮按上层走,下一轮生效。
|
|
1149
|
+
function sessionOverridesOrNull(sessionId) {
|
|
1150
|
+
if (sessionId === null) return null
|
|
1151
|
+
if (sessionOverridesKnown.has(sessionId)) return sessionOverrides.get(sessionId) || null
|
|
1152
|
+
// 首次遇到:排一次读盘。in-flight 集合防止同一会话在一轮里被排多次。
|
|
1153
|
+
if (!sessionOverridesInFlight.has(sessionId)) {
|
|
1154
|
+
sessionOverridesInFlight.add(sessionId)
|
|
1155
|
+
void loadSessionOverride(sessionId).finally(() => {
|
|
1156
|
+
sessionOverridesInFlight.delete(sessionId)
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
return null
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// 读一个会话的 sidecar 进缓存。
|
|
1163
|
+
async function loadSessionOverride(sessionId) {
|
|
1164
|
+
try {
|
|
1165
|
+
const filePath = sessionScopePathFor(configPath(), sessionId)
|
|
1166
|
+
const result = await readSessionScope(filePath, sessionId)
|
|
1167
|
+
sessionOverridesKnown.add(sessionId)
|
|
1168
|
+
if (result.override) sessionOverrides.set(sessionId, result.override)
|
|
1169
|
+
else sessionOverrides.delete(sessionId)
|
|
1170
|
+
// personaName 与 override 同一次读盘拿到 —— 不额外读一次磁盘。
|
|
1171
|
+
if (result.personaName) sessionPersonaNames.set(sessionId, result.personaName)
|
|
1172
|
+
else sessionPersonaNames.delete(sessionId)
|
|
1173
|
+
return result.override
|
|
1174
|
+
} catch {
|
|
1175
|
+
// 读失败不记为"已知":下次装配会重试(可能是临时权限问题)
|
|
1176
|
+
return null
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// 取某个会话"用户实际点选的预设名"(sidecar 里的 personaName)。
|
|
1181
|
+
//
|
|
1182
|
+
// 返回 null 有三种情况:没有 sidecar / 老版本 sidecar(没有这个字段)/
|
|
1183
|
+
// 读失败。调用方据此退回「按字段反查」—— 那条路不确定但可用。
|
|
1184
|
+
async function recordedSessionPersonaName(sessionId) {
|
|
1185
|
+
const key = String(sessionId)
|
|
1186
|
+
if (sessionPersonaNames.has(key)) return sessionPersonaNames.get(key)
|
|
1187
|
+
// 缓存未命中:确保 sidecar 已被读过(loadSessionOverride 会填缓存)
|
|
1188
|
+
await loadSessionOverride(sessionId)
|
|
1189
|
+
return sessionPersonaNames.get(key) || null
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// 清掉某个会话的缓存(写入后调用,或 GC 删文件后调用)。
|
|
1193
|
+
function forgetSessionOverride(sessionId) {
|
|
1194
|
+
sessionOverrides.delete(sessionId)
|
|
1195
|
+
sessionOverridesKnown.delete(sessionId)
|
|
1196
|
+
sessionPersonaNames.delete(sessionId)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// 会话覆盖的合并:部分覆盖 + 白名单 + 包封字段保护。
|
|
1200
|
+
//
|
|
1201
|
+
// 与 mergeWorkspaceOverride 同一条语义(字段级、不整份替换),但两处独立实现 ——
|
|
1202
|
+
// 它们服务不同的数据来源,共享会让一处改动悄悄影响另一处。语义一致性由断言守住。
|
|
1203
|
+
function mergeSessionOverride(base, override) {
|
|
1204
|
+
if (!override || typeof override !== 'object') return base
|
|
1205
|
+
const patch = {}
|
|
1206
|
+
for (const [key, value] of Object.entries(override)) {
|
|
1207
|
+
if (SESSION_ENVELOPE_ONLY_FIELDS.includes(key)) continue
|
|
1208
|
+
if (!SESSION_OVERRIDE_FIELDS.includes(key)) continue
|
|
1209
|
+
patch[key] = value
|
|
1210
|
+
}
|
|
1211
|
+
if (Object.keys(patch).length === 0) return base
|
|
1212
|
+
return { ...base, ...patch }
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// 从装配上下文里取该会话的工作目录。
|
|
1216
|
+
//
|
|
1217
|
+
// 两个都必须容忍 undefined:
|
|
1218
|
+
// · `ctx` —— 宿主在 diagnostics 装配时**不传 agent**(runtime-types.d.ts:14-19 的注释
|
|
1219
|
+
// 写明 `absent on diagnostics`)。此时回落全局配置,绝不抛错。
|
|
1220
|
+
// · `cwd` —— 会话头里可能没有这个字段。取不到时任何绑定都不会命中,
|
|
1221
|
+
// 于是行为与「没有绑定功能」完全一致(失败方向安全)。
|
|
1222
|
+
function cwdOfAgent(agent) {
|
|
1223
|
+
const cwd = agent?.session?.header?.cwd
|
|
1224
|
+
return typeof cwd === 'string' && cwd.trim() !== '' ? cwd : null
|
|
1225
|
+
}
|
|
1226
|
+
|
|
646
1227
|
// ==================== HTTP 路由 ====================
|
|
647
1228
|
|
|
648
1229
|
function registerRoutes(ctx) {
|
|
@@ -687,7 +1268,7 @@ function registerRoutes(ctx) {
|
|
|
687
1268
|
}
|
|
688
1269
|
|
|
689
1270
|
// 写队列内「读—改—写」,只合并通过校验的字段(未知字段不落盘)
|
|
690
|
-
const { config: updated, changed, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
|
|
1271
|
+
const { config: updated, changed, promptChanged } = await commitConfig(current => ({ ...current, ...patch }), { source: 'web', action: 'save' })
|
|
691
1272
|
|
|
692
1273
|
// 仅影响 Agent 行为的字段变化才刷新提示词并注入会话;
|
|
693
1274
|
// 光轨等纯外观字段只更新配置,不向会话堆积注入快照
|
|
@@ -800,7 +1381,7 @@ function registerRoutes(ctx) {
|
|
|
800
1381
|
// 经由逃生口:配置损坏时这是设置页唯一还能成功的写操作。
|
|
801
1382
|
// backupPath 非 null 说明刚才确实丢弃了一份损坏的配置(已备份),
|
|
802
1383
|
// 客户端据此给出「备份在哪」的提示,而不是让用户以为数据凭空消失。
|
|
803
|
-
const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
1384
|
+
const { config, changed, promptChanged, backupPath } = await resetConfigWithEscape({ source: 'web', action: 'reset' })
|
|
804
1385
|
|
|
805
1386
|
// 仅影响 Agent 行为的字段变化才刷新提示词并注入会话
|
|
806
1387
|
if (promptChanged.length > 0) {
|
|
@@ -852,7 +1433,18 @@ function registerRoutes(ctx) {
|
|
|
852
1433
|
// (不依赖任何缓存,见 scripts/verify-live-prompt.mjs)
|
|
853
1434
|
section: {
|
|
854
1435
|
registered: deliveryState.sectionRegisteredAt !== null,
|
|
855
|
-
registeredAt: deliveryState.sectionRegisteredAt
|
|
1436
|
+
registeredAt: deliveryState.sectionRegisteredAt,
|
|
1437
|
+
// 当前内存里的配置修订号,以及最近一次装配实际用到的那个。
|
|
1438
|
+
// 两者不相等是**正常**的(装配发生在写入之前);但用户报告「改了没生效」
|
|
1439
|
+
// 时,若 lastAssembledRevision 长期不前进,问题就锁定在装配没被触发,
|
|
1440
|
+
// 而不是配置没写进去 —— 这正是过去只能靠猜的那一步。
|
|
1441
|
+
configRevision,
|
|
1442
|
+
lastAssembledRevision: deliveryState.lastAssembledRevision,
|
|
1443
|
+
lastAssembledScope: deliveryState.lastAssembledScope,
|
|
1444
|
+
// 工作区索引:有了它才能把 sessionId 换成 workspaceId。
|
|
1445
|
+
// 为 null 表示从未重建成功 —— 此时一切解析都退化为 cwd 前缀匹配。
|
|
1446
|
+
workspaceIndexBuiltAt,
|
|
1447
|
+
workspaceIndexSize: sessionWorkspaceIndex.size
|
|
856
1448
|
},
|
|
857
1449
|
injection
|
|
858
1450
|
},
|
|
@@ -878,12 +1470,46 @@ let pendingPersonaProposal = null
|
|
|
878
1470
|
// 只遍历 PERSONA_FIELDS ⇒ **不含「关于你」(昵称 / 职业 / 介绍)**:预设是 Agent 的
|
|
879
1471
|
// 人格存档,不是使用者身份存档。切换预设不该把你的昵称职业一起换掉。
|
|
880
1472
|
// 输出语言在范围内(它决定 Agent 用什么语言作答)。
|
|
881
|
-
|
|
1473
|
+
//
|
|
1474
|
+
// draft(可选):用户在设置页**改了但还没点保存**的草稿字段(0.9.0-r)。
|
|
1475
|
+
//
|
|
1476
|
+
// 为什么必须有这个参数:设置页表单是「本地草稿 + 保存才写回」的结构,而预设原先
|
|
1477
|
+
// 只快照**已保存的配置**。用户改完风格与自定义指令、没点「保存设置」直接点「存为预设」
|
|
1478
|
+
// 时,存下的是**旧值** —— 实测确认(存出来的预设还是上一个预设的内容)。
|
|
1479
|
+
// 传草稿后语义变成「存你眼前看到的这套」。
|
|
1480
|
+
//
|
|
1481
|
+
// 只有属于 PERSONA_FIELDS 的键会被采纳;「关于你」即使夹在草稿里也进不来
|
|
1482
|
+
// (第二道防线:预设永远不含使用者身份)。
|
|
1483
|
+
function personaSnapshotOf(config, draft = null) {
|
|
882
1484
|
const values = {}
|
|
883
|
-
for (const key of PERSONA_FIELDS)
|
|
1485
|
+
for (const key of PERSONA_FIELDS) {
|
|
1486
|
+
values[key] = config[key]
|
|
1487
|
+
// 草稿优先:字段存在且类型与正式配置一致才采纳(防脏数据);
|
|
1488
|
+
// 明确的空串是合法的草稿值(用户就是想清空自定义指令)。
|
|
1489
|
+
if (draft && typeof draft === 'object' && key in draft && draft[key] !== undefined) {
|
|
1490
|
+
const value = draft[key]
|
|
1491
|
+
if (typeof value === typeof config[key] || config[key] === undefined) values[key] = value
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
884
1494
|
return values
|
|
885
1495
|
}
|
|
886
1496
|
|
|
1497
|
+
// 保存预设前的**重复门禁**(0.9.0-q)。
|
|
1498
|
+
//
|
|
1499
|
+
// 为什么需要:预设是「部分覆盖」语义,两个内容相同的预设会让 ★ 归属产生歧义
|
|
1500
|
+
// (实测:自建预设与内置预设声明字段相同,点内置的「使用」后 ★ 标在自建的那个上
|
|
1501
|
+
// —— 配置本身没错,错的只是名字标注)。上轮的 activePersonaName 记忆机制已经能
|
|
1502
|
+
// 正确处理这种歧义,本门禁是**从源头减少重复条目**,不是修 ★ 的唯一手段。
|
|
1503
|
+
//
|
|
1504
|
+
// 判据见 lib/personas.mjs 的 samePersonaContent:声明集合必须一致 + 共同声明字段值相同。
|
|
1505
|
+
// 声明子集的预设**不算重复**(应用效果不同:它不动未声明字段)。
|
|
1506
|
+
//
|
|
1507
|
+
// 返回冲突的预设名(顿号连接)或 null。
|
|
1508
|
+
function duplicatePersonaConflict(candidateEntry, library, ignoreName = null) {
|
|
1509
|
+
const dupes = findDuplicatePersonas(candidateEntry, library, ignoreName)
|
|
1510
|
+
return dupes.length === 0 ? null : dupes.join('、')
|
|
1511
|
+
}
|
|
1512
|
+
|
|
887
1513
|
// 从预设条目中挑出人设字段(忽略 updatedAt 等元数据与「关于你」)。
|
|
888
1514
|
// `if (key in persona)` 即「部分覆盖」语义:只应用条目**实际声明**的键,
|
|
889
1515
|
// 未声明的保持用户当前值;而遍历白名单本身则是第二道防线——即便磁盘上残留了
|
|
@@ -909,6 +1535,11 @@ function hasOwn(obj, key) {
|
|
|
909
1535
|
|
|
910
1536
|
// 计算与当前活动配置匹配的预设名(多个命中时取名称排序最前)。
|
|
911
1537
|
// 匹配语义是「部分覆盖」而非全字段相等,理由见 lib/personas.mjs 的 personaMatches。
|
|
1538
|
+
//
|
|
1539
|
+
// ⚠️ 反查是**不确定**的:两个预设声明相同字段时(自建预设是内置预设的子集很常见),
|
|
1540
|
+
// 都会命中,然后按名称排序取首个 —— 实测 ★ 标在了没点的卡上。
|
|
1541
|
+
// 所以调用方应**优先用 config.activePersonaName**(用户实际点的名字,0.9.0-p),
|
|
1542
|
+
// 本函数只在没有记录(从没用过「使用预设」)或记录的预设已不存在时作兜底。
|
|
912
1543
|
function findActivePersonaName(config) {
|
|
913
1544
|
const view = mergePersonas(config.personas)
|
|
914
1545
|
for (const personaName of Object.keys(view).sort()) {
|
|
@@ -917,6 +1548,22 @@ function findActivePersonaName(config) {
|
|
|
917
1548
|
return null
|
|
918
1549
|
}
|
|
919
1550
|
|
|
1551
|
+
// ★ 当前生效的预设名:优先用**记住的名字**,反查只作兜底。
|
|
1552
|
+
//
|
|
1553
|
+
// 三种情况退回反查:从没用过「使用预设」(activePersonaName 为 null)/
|
|
1554
|
+
// 记住的预设已被删除 / 记住的预设现在不再匹配(用户手改了特质 —— 此时按
|
|
1555
|
+
// 「改任何特质 ⇒ 不再属于该预设」的语义,★ 本来就该消失)。
|
|
1556
|
+
function resolveActivePersonaName(config) {
|
|
1557
|
+
const recorded = typeof config.activePersonaName === 'string' && config.activePersonaName !== ''
|
|
1558
|
+
? config.activePersonaName
|
|
1559
|
+
: null
|
|
1560
|
+
if (recorded) {
|
|
1561
|
+
const view = mergePersonas(config.personas)
|
|
1562
|
+
if (view[recorded] && personaMatches(view[recorded], config)) return recorded
|
|
1563
|
+
}
|
|
1564
|
+
return findActivePersonaName(config)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
920
1567
|
// 应用一个预设条目:过白名单 → 写队列合并。
|
|
921
1568
|
//
|
|
922
1569
|
// **必须回报被丢弃的字段**:normalizePersonas 只按字段名过滤、不校验取值,所以磁盘上
|
|
@@ -928,10 +1575,25 @@ function findActivePersonaName(config) {
|
|
|
928
1575
|
// 语义选择:应用**合法部分**并如实回报被丢弃的字段,而不是整单拒绝 —— 后者会让一个
|
|
929
1576
|
// 只差一个字段的旧预设彻底不可用,代价更高;而「部分应用 + 明示」与 /api/soul/prompt/
|
|
930
1577
|
// preview 的 invalid 约定一致。
|
|
1578
|
+
//
|
|
1579
|
+
// 来源固定为 persona/use:两个调用入口(网页「使用」与 /soul use)是同一个动作,
|
|
1580
|
+
// 用户视角下「用了个预设」这件事本身比「从哪个入口点的」更重要,故不细分。
|
|
931
1581
|
async function applyPersonaEntry(ctx, entry) {
|
|
932
1582
|
const { patch, errors } = sanitizeConfig(pickPersonaValues(entry))
|
|
933
1583
|
const invalid = Object.keys(errors)
|
|
934
|
-
|
|
1584
|
+
// label 带上预设名:审计里「应用预设」这条记录光有字段差,缺「应用的是什么」——
|
|
1585
|
+
// 用户问「昨晚我切到了哪个人设」时,字段差答不上来。
|
|
1586
|
+
//
|
|
1587
|
+
// 同时把**用户实际点的名字**写进配置(activePersonaName,0.9.0-p):
|
|
1588
|
+
// ★ 标记原先靠字段反查,两个预设声明相同字段时(自建预设是内置的子集很常见)
|
|
1589
|
+
// 反查结果取决于名称排序 —— 实测 ★ 标在了没点的卡上。记名字把"猜"变成"知道"。
|
|
1590
|
+
// 名字是注解不是人设字段(不进提示词),所以放在 patch 之外单独合并。
|
|
1591
|
+
const personaName = entry && typeof entry.name === 'string' ? entry.name.trim() : ''
|
|
1592
|
+
const namePatch = personaName ? { activePersonaName: personaName } : {}
|
|
1593
|
+
const { config: updated, changed, promptChanged } = await commitConfig(
|
|
1594
|
+
c => ({ ...c, ...patch, ...namePatch }),
|
|
1595
|
+
{ source: 'persona', action: 'use', label: personaName || undefined }
|
|
1596
|
+
)
|
|
935
1597
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
936
1598
|
// applied 是「通过校验的字段数」——与 changed(实际产生差异的字段)不同:
|
|
937
1599
|
// 全部非法时 applied 为 0,此时不能报「已应用」,也不能报「与当前配置一致」。
|
|
@@ -940,7 +1602,7 @@ async function applyPersonaEntry(ctx, entry) {
|
|
|
940
1602
|
|
|
941
1603
|
// 预设库的对外视图(内置 + 用户)与当前命中项,供三个路由的响应复用。
|
|
942
1604
|
function personaLibrary(config) {
|
|
943
|
-
return { personas: mergePersonas(config.personas), activeName:
|
|
1605
|
+
return { personas: mergePersonas(config.personas), activeName: resolveActivePersonaName(config) }
|
|
944
1606
|
}
|
|
945
1607
|
|
|
946
1608
|
// 人设预设 HTTP 路由:列表 / 保存 / 使用 / 删除。
|
|
@@ -997,11 +1659,45 @@ function registerPersonaRoutes(ctx) {
|
|
|
997
1659
|
send(res, 400, { ok: false, error: `「${personaName}」是内置预设,请换一个名称` })
|
|
998
1660
|
return
|
|
999
1661
|
}
|
|
1662
|
+
// 草稿字段(0.9.0-r):用户在设置页改了但**还没点保存**的内容。
|
|
1663
|
+
//
|
|
1664
|
+
// 为什么要收它:设置页是「本地草稿 + 保存才写回」的结构,若预设只快照
|
|
1665
|
+
// 已保存配置,用户改完风格/自定义指令后直接点「存为预设」,存下的是**旧值**
|
|
1666
|
+
// —— 实测确认(存出来的预设仍是上一个预设的内容)。
|
|
1667
|
+
//
|
|
1668
|
+
// 只取人设字段(PERSONA_FIELDS);「关于你」即使夹在草稿里也进不来。
|
|
1669
|
+
const draft = (() => {
|
|
1670
|
+
const raw = parsed.body && parsed.body.draft
|
|
1671
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
1672
|
+
const picked = {}
|
|
1673
|
+
for (const key of PERSONA_FIELDS) {
|
|
1674
|
+
if (key in raw && raw[key] !== undefined) picked[key] = raw[key]
|
|
1675
|
+
}
|
|
1676
|
+
return Object.keys(picked).length > 0 ? picked : null
|
|
1677
|
+
})()
|
|
1678
|
+
// 重复门禁(0.9.0-q):内容与已有预设完全相同则拒绝创建。
|
|
1679
|
+
// 判据在 lib/personas.mjs 的 samePersonaContent —— 投影比较。
|
|
1680
|
+
// ⚠️ 比对对象必须是**草稿构造的快照**(0.9.0-r):否则用户改了草稿想存
|
|
1681
|
+
// 新预设时,门禁看到的是旧配置 —— 会误判成"与某个内置重复"而拒绝。
|
|
1682
|
+
// 「覆盖同名」时把同名条目排除在比对之外(那是更新,不是新增重复)。
|
|
1683
|
+
{
|
|
1684
|
+
const current = await loadConfig()
|
|
1685
|
+
const candidate = personaSnapshotOf(current, draft)
|
|
1686
|
+
const dupes = duplicatePersonaConflict(candidate, mergePersonas(current.personas), personaName)
|
|
1687
|
+
if (dupes) {
|
|
1688
|
+
send(res, 400, {
|
|
1689
|
+
ok: false,
|
|
1690
|
+
error: `已存在内容完全相同的预设「${dupes}」,请直接使用它,或先修改人设再保存`,
|
|
1691
|
+
duplicates: dupes.split('、')
|
|
1692
|
+
})
|
|
1693
|
+
return
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1000
1696
|
const { config: updated } = await commitConfig((current) => {
|
|
1001
1697
|
const personas = clonePersonas(current.personas)
|
|
1002
|
-
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
1698
|
+
personas[personaName] = { ...personaSnapshotOf(current, draft), updatedAt: new Date().toISOString() }
|
|
1003
1699
|
return { ...current, personas }
|
|
1004
|
-
})
|
|
1700
|
+
}, { source: 'web', action: 'savePreset' })
|
|
1005
1701
|
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
1006
1702
|
} catch (err) {
|
|
1007
1703
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
@@ -1080,7 +1776,7 @@ function registerPersonaRoutes(ctx) {
|
|
|
1080
1776
|
const personas = clonePersonas(c.personas)
|
|
1081
1777
|
delete personas[personaName]
|
|
1082
1778
|
return { ...c, personas }
|
|
1083
|
-
})
|
|
1779
|
+
}, { source: 'web', action: 'deletePreset' })
|
|
1084
1780
|
send(res, 200, { ok: true, ...personaLibrary(updated) })
|
|
1085
1781
|
} catch (err) {
|
|
1086
1782
|
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
@@ -1129,91 +1825,1155 @@ function registerPersonaRoutes(ctx) {
|
|
|
1129
1825
|
})
|
|
1130
1826
|
}
|
|
1131
1827
|
|
|
1132
|
-
// ====================
|
|
1828
|
+
// ==================== 变更记录 ====================
|
|
1133
1829
|
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
//
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1830
|
+
// 解析 ?limit=。非法值一律当作「没给」而不是报错:这是一个纯展示参数,
|
|
1831
|
+
// 为一个拼错的查询串返回 400 只会让客户端多写一段没必要的错误处理。
|
|
1832
|
+
function parseLimit(rawUrl) {
|
|
1833
|
+
try {
|
|
1834
|
+
const raw = new URL(String(rawUrl || ''), 'http://localhost').searchParams.get('limit')
|
|
1835
|
+
if (raw === null) return null
|
|
1836
|
+
const value = Number.parseInt(raw, 10)
|
|
1837
|
+
if (!Number.isFinite(value) || value <= 0) return null
|
|
1838
|
+
return Math.min(value, MAX_READ_LINES)
|
|
1839
|
+
} catch {
|
|
1840
|
+
return null
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
// 变更记录的四条路由:列表 / 撤销 / 恢复预设 / 清空。
|
|
1845
|
+
//
|
|
1846
|
+
// 一律先校验 req.method(同文件其余写路由的规矩,来自 /api/soul/config/reset
|
|
1847
|
+
// 曾经任何方法——包括 GET——都能触发破坏性写入的教训)。
|
|
1848
|
+
//
|
|
1849
|
+
// 撤销与恢复预设都走唯一漏斗 commitConfig,于是「我撤销过一次」本身也留下一条记录:
|
|
1850
|
+
// 审计因此是链条而不是快照,能看出「改了、撤了、又改回去」。
|
|
1851
|
+
function registerHistoryRoutes(ctx) {
|
|
1852
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
1853
|
+
const send = (res, status, body) => {
|
|
1854
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
1855
|
+
res.end(JSON.stringify(body))
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// 列表(最新在前)
|
|
1859
|
+
wsCtx.webServer.register({
|
|
1860
|
+
kind: 'exact',
|
|
1861
|
+
path: '/api/soul/history',
|
|
1862
|
+
handler: async (req, res) => {
|
|
1863
|
+
try {
|
|
1864
|
+
if (req.method !== 'GET') {
|
|
1865
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
1866
|
+
return
|
|
1867
|
+
}
|
|
1868
|
+
const read = await readHistoryFile(historyPath())
|
|
1869
|
+
// max 必须来自**当前配置**(用户改了保留条数,UI 的「最多保留 N 条」就得跟着变);
|
|
1870
|
+
// 读配置失败时回退默认值 —— 列表读取不该因为配置层的抖动而 500。
|
|
1871
|
+
let maxEntries
|
|
1872
|
+
try {
|
|
1873
|
+
maxEntries = (await loadConfig()).historyMaxEntries
|
|
1874
|
+
} catch {
|
|
1875
|
+
maxEntries = undefined
|
|
1876
|
+
}
|
|
1877
|
+
const payload = toHistoryResponse(read, lastHistoryFailure, maxEntries)
|
|
1878
|
+
// ?limit= 只影响本次返回的条数,**不动 max** —— max 是容量上限(压实后保多少条),
|
|
1879
|
+
// 拿它当「本次有多少条」用,客户端会在两种含义之间猜。
|
|
1880
|
+
const limit = parseLimit(req.url)
|
|
1881
|
+
if (limit !== null && payload.entries.length > limit) {
|
|
1882
|
+
payload.entries = payload.entries.slice(0, limit)
|
|
1883
|
+
}
|
|
1884
|
+
send(res, 200, { ok: true, ...payload })
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
})
|
|
1890
|
+
|
|
1891
|
+
// 撤销:把配置退回到目标条目**发生之前**的状态(连带撤销它之后的所有改动)
|
|
1892
|
+
wsCtx.webServer.register({
|
|
1893
|
+
kind: 'exact',
|
|
1894
|
+
path: '/api/soul/history/rollback',
|
|
1895
|
+
handler: async (req, res) => {
|
|
1896
|
+
try {
|
|
1897
|
+
if (req.method !== 'POST') {
|
|
1898
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
1899
|
+
return
|
|
1900
|
+
}
|
|
1901
|
+
const parsed = await readJsonBody(req)
|
|
1902
|
+
if (!parsed.ok) {
|
|
1903
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1906
|
+
const id = typeof (parsed.body && parsed.body.id) === 'string' ? parsed.body.id : ''
|
|
1907
|
+
if (!id) {
|
|
1908
|
+
send(res, 400, { ok: false, error: '缺少要撤销的记录 id' })
|
|
1909
|
+
return
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
let plan = null
|
|
1913
|
+
const { config: updated, changed, promptChanged, entry } = await commitConfig(async (current) => {
|
|
1914
|
+
// 记录必须在**队列内**读:放到队列外读会与并发写入形成窗口,规划建立在
|
|
1915
|
+
// 已被别人改过的配置上,指纹校验随后报出一个看起来像「数据损坏」的
|
|
1916
|
+
// diverged,把排查方向带偏。
|
|
1917
|
+
const read = await readHistoryFile(historyPath())
|
|
1918
|
+
if (read.error) throw httpError(500, read.error)
|
|
1919
|
+
plan = planRollback(read.entries, current, id)
|
|
1920
|
+
if (!plan.ok) {
|
|
1921
|
+
if (plan.reason === 'missing') {
|
|
1922
|
+
throw httpError(400, `找不到这条变更记录:${id}。它可能已被清空或压实淘汰。`)
|
|
1923
|
+
}
|
|
1924
|
+
// diverged:配置与记录不同步(有人手改过配置文件)。不给一个猜出来的结果。
|
|
1925
|
+
throw httpError(409, '记录与当前配置对不上,无法撤销。可能有人手工改过配置文件。')
|
|
1926
|
+
}
|
|
1927
|
+
// 补丁为空是**合法**结果(目标那次改动已被后续改动改回来了),
|
|
1928
|
+
// 此时不改任何字段、也不会留下记录,由 unchanged 告诉客户端「已经是最新」。
|
|
1929
|
+
if (Object.keys(plan.patch).length === 0) return { ...current }
|
|
1930
|
+
return { ...current, ...plan.patch }
|
|
1931
|
+
}, { source: 'rollback', action: 'rollback' })
|
|
1932
|
+
|
|
1933
|
+
// 仅影响 Agent 行为的字段变化才刷新提示词注入(与其余写路径同一门控)
|
|
1934
|
+
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1935
|
+
|
|
1936
|
+
send(res, 200, {
|
|
1937
|
+
ok: true,
|
|
1938
|
+
config: updated,
|
|
1939
|
+
changed,
|
|
1940
|
+
skipped: plan ? plan.skipped : [],
|
|
1941
|
+
reversed: plan ? plan.reversed : [],
|
|
1942
|
+
unchanged: changed.length === 0,
|
|
1943
|
+
entry,
|
|
1944
|
+
deliveryWarning: deliveryProblemText(updated)
|
|
1945
|
+
})
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
const status = err && err.httpStatus ? err.httpStatus : 500
|
|
1948
|
+
send(res, status, { ok: false, error: String((err && err.message) || err) })
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1953
|
+
// 恢复预设:只对「删除预设」这类条目有意义(条目里带着被删条目的内容)
|
|
1954
|
+
wsCtx.webServer.register({
|
|
1955
|
+
kind: 'exact',
|
|
1956
|
+
path: '/api/soul/history/restore-persona',
|
|
1957
|
+
handler: async (req, res) => {
|
|
1958
|
+
try {
|
|
1959
|
+
if (req.method !== 'POST') {
|
|
1960
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
1961
|
+
return
|
|
1962
|
+
}
|
|
1963
|
+
const parsed = await readJsonBody(req)
|
|
1964
|
+
if (!parsed.ok) {
|
|
1965
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
1966
|
+
return
|
|
1967
|
+
}
|
|
1968
|
+
const id = typeof (parsed.body && parsed.body.id) === 'string' ? parsed.body.id : ''
|
|
1969
|
+
if (!id) {
|
|
1970
|
+
send(res, 400, { ok: false, error: '缺少记录 id' })
|
|
1971
|
+
return
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const restored = []
|
|
1975
|
+
const skipped = []
|
|
1976
|
+
const { config: updated, changed, entry } = await commitConfig(async (current) => {
|
|
1977
|
+
const read = await readHistoryFile(historyPath())
|
|
1978
|
+
if (read.error) throw httpError(500, read.error)
|
|
1979
|
+
const index = findEntryIndex(read.entries, id)
|
|
1980
|
+
if (index < 0) {
|
|
1981
|
+
throw httpError(400, `找不到这条变更记录:${id}。它可能已被清空或压实淘汰。`)
|
|
1982
|
+
}
|
|
1983
|
+
const removed = read.entries[index].personas.removed
|
|
1984
|
+
if (removed.length === 0) {
|
|
1985
|
+
throw httpError(400, '这条记录里没有被删除的预设,无法恢复。')
|
|
1986
|
+
}
|
|
1987
|
+
const personas = clonePersonas(current.personas)
|
|
1988
|
+
for (const item of removed) {
|
|
1989
|
+
// 逐个校验、逐个说明为什么没恢复。**绝不静默覆盖同名条目**:
|
|
1990
|
+
// 删掉「架构师」之后完全可能又新建了一个同名但内容不同的预设,
|
|
1991
|
+
// 此时「恢复」若是直接写回,等于把用户后来的编辑悄悄扔掉。
|
|
1992
|
+
const personaName = sanitizePersonaName(item.name)
|
|
1993
|
+
if (!personaName) {
|
|
1994
|
+
skipped.push({ name: item.name, reason: 'invalid-name' })
|
|
1995
|
+
continue
|
|
1996
|
+
}
|
|
1997
|
+
if (isBuiltinPersona(personaName)) {
|
|
1998
|
+
skipped.push({ name: personaName, reason: 'builtin' })
|
|
1999
|
+
continue
|
|
2000
|
+
}
|
|
2001
|
+
if (hasOwn(personas, personaName)) {
|
|
2002
|
+
skipped.push({ name: personaName, reason: 'exists' })
|
|
2003
|
+
continue
|
|
2004
|
+
}
|
|
2005
|
+
personas[personaName] = { ...item.entry }
|
|
2006
|
+
restored.push(personaName)
|
|
2007
|
+
}
|
|
2008
|
+
if (restored.length === 0) {
|
|
2009
|
+
throw httpError(400, `没有可恢复的预设:${skipped.map((item) => item.name).join('、')}`)
|
|
2010
|
+
}
|
|
2011
|
+
return { ...current, personas }
|
|
2012
|
+
}, { source: 'restorePersona', action: 'restorePreset' })
|
|
2013
|
+
|
|
2014
|
+
// 预设库变化不触发会话注入(diffKeys 本就跳过 personas),故此处不刷新提示词
|
|
2015
|
+
send(res, 200, { ok: true, restored, skipped, changed, entry, ...personaLibrary(updated) })
|
|
2016
|
+
} catch (err) {
|
|
2017
|
+
const status = err && err.httpStatus ? err.httpStatus : 500
|
|
2018
|
+
send(res, status, { ok: false, error: String((err && err.message) || err) })
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
})
|
|
2022
|
+
|
|
2023
|
+
// 清空:隐私出口(记录里有昵称 / 职业 / 介绍 / 自定义指令的原文)
|
|
2024
|
+
wsCtx.webServer.register({
|
|
2025
|
+
kind: 'exact',
|
|
2026
|
+
path: '/api/soul/history/clear',
|
|
2027
|
+
handler: async (req, res) => {
|
|
2028
|
+
try {
|
|
2029
|
+
if (req.method !== 'POST') {
|
|
2030
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2031
|
+
return
|
|
2032
|
+
}
|
|
2033
|
+
// 借配置写队列串行:记录是在提交路径里写的,清空若不入队就会与并发的追加
|
|
2034
|
+
// 撞在一起 —— 最坏的结果是「刚清完又冒出一条」,用户会以为清空没生效。
|
|
2035
|
+
const removed = await enqueueConfigWrite(async () => {
|
|
2036
|
+
const count = await clearHistoryFile(historyPath())
|
|
2037
|
+
lastHistoryFailure = null
|
|
2038
|
+
return count
|
|
2039
|
+
})
|
|
2040
|
+
send(res, 200, { ok: true, removed })
|
|
2041
|
+
} catch (err) {
|
|
2042
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
})
|
|
2046
|
+
})
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// ==================== 工作区绑定路由 ====================
|
|
2050
|
+
|
|
2051
|
+
// 绑定表的写队列。与配置队列(enqueueConfigWrite)**刻意是两个实例**(设计 §4.3):
|
|
2052
|
+
// 两套数据在因果上无关 —— 绑定的写入不依赖配置的当前值,反之亦然 —— 共用一个队列
|
|
2053
|
+
// 只会把两者的延迟叠在一起,一次慢写的绑定请求会挡住配置保存。
|
|
2054
|
+
let bindingsWriteQueue = Promise.resolve()
|
|
2055
|
+
|
|
2056
|
+
function enqueueBindingsWrite(task) {
|
|
2057
|
+
const run = bindingsWriteQueue.then(task)
|
|
2058
|
+
bindingsWriteQueue = run.then(() => {}, () => {})
|
|
2059
|
+
return run
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// 活动会话的目录与解析结果,供设置页回答「我这个项目现在是谁」。
|
|
2063
|
+
//
|
|
2064
|
+
// 为什么必须由宿主算:设置页的槽位 scope 是 `root`(设计 §7.7),它**拿不到
|
|
2065
|
+
// sessionId**,也就拿不到会话 cwd。宿主遍历 agents.list() 是唯一权威的来源。
|
|
2066
|
+
//
|
|
2067
|
+
// 取 `session.header.cwd` 而不是工作区根:两者可能不同(工作区 D:\AliuProject、
|
|
2068
|
+
// 会话 cwd D:\AliuProject\dsh-plugin),显示错粒度会让用户绑错层级。
|
|
2069
|
+
function describeActiveSessions(ctx, config) {
|
|
2070
|
+
const out = []
|
|
2071
|
+
const agents = ctx.get('agents')
|
|
2072
|
+
if (!agents || typeof agents.list !== 'function') return out
|
|
2073
|
+
|
|
2074
|
+
let listed
|
|
2075
|
+
try {
|
|
2076
|
+
listed = agents.list()
|
|
2077
|
+
} catch {
|
|
2078
|
+
// 列不出活动会话不是错误:设置页只是少一段「当前用的谁」,仍能管理绑定表。
|
|
2079
|
+
return out
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
for (const agent of listed) {
|
|
2083
|
+
const cwd = cwdOfAgent(agent)
|
|
2084
|
+
const id = agent && typeof agent.id === 'string' ? agent.id : null
|
|
2085
|
+
|
|
2086
|
+
if (!cwd) {
|
|
2087
|
+
// cwd 缺席是宿主允许的情况(会话没有工作目录)。必须如实说出来 ——
|
|
2088
|
+
// 让用户对着空白猜「为什么绑了没生效」,不如直接告诉他这个会话没有目录。
|
|
2089
|
+
out.push({ id, cwd: null, persona: resolveActivePersonaName(config), binding: null, shadowed: [] })
|
|
2090
|
+
continue
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const resolved = resolveBindingForCwd(cwd, config.personas)
|
|
2094
|
+
// 生效人设走与真实装配**同一条路径**(configForCwd → 部分覆盖 → findActivePersonaName),
|
|
2095
|
+
// 否则设置页显示的「生效人设」与实际注入的可能不一致 —— 那正是最难查的一类 bug。
|
|
2096
|
+
const effective = configForCwd(config, cwd).config
|
|
2097
|
+
|
|
2098
|
+
let binding = null
|
|
2099
|
+
if (resolved) {
|
|
2100
|
+
// 「直接绑定」与「继承自上级」的判别**必须在宿主做**:客户端拿原始串比较时,
|
|
2101
|
+
// Windows 上 `D:\项目A` 与 `d:\项目a` 会被判成不同目录,于是「就是本目录」
|
|
2102
|
+
// 被显示成「继承自上级」,用户会去找一条并不存在的父级绑定。
|
|
2103
|
+
const sameDir = compareKey(normalizeDirPath(resolved.dir)) === compareKey(normalizeDirPath(cwd))
|
|
2104
|
+
binding = { dir: resolved.dir, persona: resolved.persona, inherited: !sameDir }
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
out.push({
|
|
2108
|
+
id,
|
|
2109
|
+
cwd,
|
|
2110
|
+
persona: resolveActivePersonaName(effective),
|
|
2111
|
+
binding,
|
|
2112
|
+
shadowed: resolved ? resolved.shadowed : []
|
|
2113
|
+
})
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
return out
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// 绑定表的可读形态(列表 + 失效标注 + 解析结果)。
|
|
2120
|
+
async function bindingsView(ctx) {
|
|
2121
|
+
const cache = await ensureBindings()
|
|
2122
|
+
const config = await loadConfig()
|
|
2123
|
+
|
|
2124
|
+
// 失效引用:指向的人设已不存在。**不自动删除**(设计 §4.2)——用户可能只是临时
|
|
2125
|
+
// 删了那个预设、打算重命名回来。但必须显式标注,否则用户在目录里等一个永远
|
|
2126
|
+
// 不会来的人设。
|
|
2127
|
+
const invalid = []
|
|
2128
|
+
for (const dir of Object.keys(cache.bindings)) {
|
|
2129
|
+
const name = cache.bindings[dir]
|
|
2130
|
+
if (!resolvePersona(name, config.personas)) invalid.push({ dir, persona: name })
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
return {
|
|
2134
|
+
bindings: cache.bindings,
|
|
2135
|
+
invalid,
|
|
2136
|
+
dropped: cache.dropped,
|
|
2137
|
+
// warning 是「整表没读进来」或「有条目被丢弃」,与「一条都没绑」是两件事。
|
|
2138
|
+
warning: bindingsLoadFailure,
|
|
2139
|
+
max: MAX_BINDINGS,
|
|
2140
|
+
maxPathLength: MAX_PATH_LENGTH,
|
|
2141
|
+
sessions: describeActiveSessions(ctx, config)
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
function registerBindingRoutes(ctx) {
|
|
2146
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
2147
|
+
const send = (res, status, body) => {
|
|
2148
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
2149
|
+
res.end(JSON.stringify(body))
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// 写完之后统一收尾:重载绑定表 → 刷新提示词并注入。
|
|
2153
|
+
//
|
|
2154
|
+
// 这是**第三类**会改变提示词的事实(设计 §6.4):它不改 soul-config.json,
|
|
2155
|
+
// 所以 commitConfig 的 promptChanged 门控看不见它。绑定路由必须自己触发,
|
|
2156
|
+
// 否则「刚绑好,会话里却不生效,要新开会话才行」。
|
|
2157
|
+
const afterBindingsWrite = async (config) => {
|
|
2158
|
+
await loadBindings()
|
|
2159
|
+
const fresh = config || (await loadConfig())
|
|
2160
|
+
refreshPromptAndInject(ctx, fresh)
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// GET = 读取(绑定表 + 当前活动会话的解析结果);POST = 新增 / 改绑。
|
|
2164
|
+
//
|
|
2165
|
+
// 同一 (kind, path) 只能 register **一次**:宿主 dsh-host-webserver 的
|
|
2166
|
+
// `register()` 对重复路径直接 `throw new Error('webserver: duplicate exact
|
|
2167
|
+
// route "' + path + '"')`,而这里是 ctx.inject 回调 —— 一次重复注册会让
|
|
2168
|
+
// 整个插件装配失败。故方法分派必须写在 handler 内,这也正是本文件其余
|
|
2169
|
+
// 14 条路由的范式。
|
|
2170
|
+
wsCtx.webServer.register({
|
|
2171
|
+
kind: 'exact',
|
|
2172
|
+
path: '/api/soul/bindings',
|
|
2173
|
+
handler: async (req, res) => {
|
|
2174
|
+
if (req.method === 'GET') {
|
|
2175
|
+
try {
|
|
2176
|
+
send(res, 200, { ok: true, ...(await bindingsView(ctx)) })
|
|
2177
|
+
} catch (err) {
|
|
2178
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2179
|
+
}
|
|
2180
|
+
return
|
|
2181
|
+
}
|
|
2182
|
+
if (req.method !== 'POST') {
|
|
2183
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2184
|
+
return
|
|
2185
|
+
}
|
|
2186
|
+
try {
|
|
2187
|
+
const parsed = await readJsonBody(req)
|
|
2188
|
+
if (!parsed.ok) {
|
|
2189
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2190
|
+
return
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
const config = await loadConfig()
|
|
2194
|
+
// 准入检查挑出三类拒绝(路径非法 / 过长 / 人设不存在),
|
|
2195
|
+
// 原因文案可直接展示 —— 客户端不得再包一层前缀。
|
|
2196
|
+
const refusal = bindingRefusal(parsed.body && parsed.body.dir, parsed.body && parsed.body.persona, config.personas)
|
|
2197
|
+
if (refusal) {
|
|
2198
|
+
send(res, 400, { ok: false, error: refusal })
|
|
2199
|
+
return
|
|
2200
|
+
}
|
|
2201
|
+
const entry = normalizeBindingEntry(parsed.body && parsed.body.dir, parsed.body && parsed.body.persona, config.personas)
|
|
2202
|
+
if (!entry) {
|
|
2203
|
+
send(res, 400, { ok: false, error: '绑定内容不合法,未写入。' })
|
|
2204
|
+
return
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
await enqueueBindingsWrite(async () => {
|
|
2208
|
+
// 队列内读盘再合并:队列外读会与并发写入形成窗口,后写覆盖前写。
|
|
2209
|
+
const read = await readBindingsFile(bindingsPath())
|
|
2210
|
+
if (read.failure) throw httpError(500, read.failure)
|
|
2211
|
+
const table = { ...read.bindings }
|
|
2212
|
+
// 已存在的目录改绑不算新增,只有真新增才受条数上限约束 ——
|
|
2213
|
+
// 否则满表之后连改绑都做不了,用户只能先删再绑。
|
|
2214
|
+
const isNew = !hasOwn(table, entry.dir)
|
|
2215
|
+
if (isNew && Object.keys(table).length >= MAX_BINDINGS) {
|
|
2216
|
+
throw httpError(400, `绑定数量已达上限(${MAX_BINDINGS} 条),请先删除不再需要的绑定。`)
|
|
2217
|
+
}
|
|
2218
|
+
table[entry.dir] = entry.persona
|
|
2219
|
+
await writeBindingsFile(bindingsPath(), table)
|
|
2220
|
+
})
|
|
2221
|
+
|
|
2222
|
+
await afterBindingsWrite(config)
|
|
2223
|
+
send(res, 200, { ok: true, ...(await bindingsView(ctx)) })
|
|
2224
|
+
} catch (err) {
|
|
2225
|
+
// 队列内抛出的 httpError 带状态码;其余按 500 处理。
|
|
2226
|
+
send(res, err.httpStatus || 500, { ok: false, error: String(err.message || err) })
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
})
|
|
2230
|
+
|
|
2231
|
+
// 解绑
|
|
2232
|
+
wsCtx.webServer.register({
|
|
2233
|
+
kind: 'exact',
|
|
2234
|
+
path: '/api/soul/bindings/delete',
|
|
2235
|
+
handler: async (req, res) => {
|
|
2236
|
+
try {
|
|
2237
|
+
if (req.method !== 'POST') {
|
|
2238
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2239
|
+
return
|
|
2240
|
+
}
|
|
2241
|
+
const parsed = await readJsonBody(req)
|
|
2242
|
+
if (!parsed.ok) {
|
|
2243
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2244
|
+
return
|
|
2245
|
+
}
|
|
2246
|
+
const target = normalizeDirPath(parsed.body && parsed.body.dir)
|
|
2247
|
+
if (!target) {
|
|
2248
|
+
send(res, 400, { ok: false, error: '需要一个绝对路径才能解绑。' })
|
|
2249
|
+
return
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
const config = await loadConfig()
|
|
2253
|
+
let removed = false
|
|
2254
|
+
await enqueueBindingsWrite(async () => {
|
|
2255
|
+
const read = await readBindingsFile(bindingsPath())
|
|
2256
|
+
if (read.failure) throw httpError(500, read.failure)
|
|
2257
|
+
const table = { ...read.bindings }
|
|
2258
|
+
// 按**比较键**找:用户点了列表里那一行,但配置里存的可能是另一种写法
|
|
2259
|
+
// (分隔符、尾分隔符、盘符大小写)。拿原串直接 delete 会「点了没反应」。
|
|
2260
|
+
const key = Object.keys(table).find(dir => compareKey(dir) === compareKey(target))
|
|
2261
|
+
if (key === undefined) return
|
|
2262
|
+
delete table[key]
|
|
2263
|
+
removed = true
|
|
2264
|
+
await writeBindingsFile(bindingsPath(), table)
|
|
2265
|
+
})
|
|
2266
|
+
|
|
2267
|
+
// 没删掉任何东西时**不刷新**:一次空解绑不该向所有活动会话注入快照。
|
|
2268
|
+
if (removed) await afterBindingsWrite(config)
|
|
2269
|
+
send(res, 200, { ok: true, removed, ...(await bindingsView(ctx)) })
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
send(res, err.httpStatus || 500, { ok: false, error: String(err.message || err) })
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
})
|
|
2275
|
+
})
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// ==================== 工作区索引(sessionId → workspaceId)====================
|
|
2279
|
+
|
|
2280
|
+
// 维护 sessionWorkspaceIndex 的三条时机(docs/DESIGN-config-scope.md §2.2):
|
|
2281
|
+
//
|
|
2282
|
+
// ① 首轮重建 —— 服务刚就绪时索引必然是空的;
|
|
2283
|
+
// ② 宿主工作区变更 —— 加/删工作区、会话移入移出,都会改变映射;
|
|
2284
|
+
// ③ 兜底重扫 —— 装配时发现「有 sessionId 但索引里没有」就标记,下一拍重建。
|
|
2285
|
+
//
|
|
2286
|
+
// ③ 是必需的:②依赖宿主的事件名(内部约定,可能改名)。没有兜底的话,
|
|
2287
|
+
// 事件一旦改名,索引会**静默停止更新** —— 表现为「工作区绑定时灵时不灵」,
|
|
2288
|
+
// 而 ③ 让它的失败形态退化成「多扫一次内存投影」,代价可以忽略。
|
|
2289
|
+
function registerWorkspaceIndex(ctx) {
|
|
2290
|
+
const rebuild = () => rebuildWorkspaceIndex(ctx)
|
|
2291
|
+
|
|
2292
|
+
// ① 首轮:延后一拍,给宿主服务一个物化窗口。
|
|
2293
|
+
const firstTimer = setTimeout(rebuild, 0)
|
|
2294
|
+
if (typeof firstTimer.unref === 'function') firstTimer.unref()
|
|
2295
|
+
|
|
2296
|
+
// ② 事件:宿主工作区域变更。事件名取自 dsh-workspace 的 domain 事件。
|
|
2297
|
+
// 订阅失败(宿主无该事件)不是错误 —— 还有 ③ 兜底。
|
|
2298
|
+
try {
|
|
2299
|
+
ctx.on('domain/changed', () => { rebuild() })
|
|
2300
|
+
} catch {
|
|
2301
|
+
// 事件名不存在时 ctx.on 可能直接抛错;吞掉,靠 ③。
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// ③ 兜底:装配时未命中的 sessionId 会被记下来,这里周期性重扫。
|
|
2305
|
+
// 用 5s 而不是每轮:索引是内存投影,重建本身很轻,但没必要每个 step 都做。
|
|
2306
|
+
const sweepTimer = setInterval(() => {
|
|
2307
|
+
if (pendingWorkspaceRescan === 0) return
|
|
2308
|
+
pendingWorkspaceRescan = 0
|
|
2309
|
+
rebuild()
|
|
2310
|
+
}, 5000)
|
|
2311
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref()
|
|
2312
|
+
|
|
2313
|
+
ctx.effect(() => () => {
|
|
2314
|
+
clearTimeout(firstTimer)
|
|
2315
|
+
clearInterval(sweepTimer)
|
|
2316
|
+
}, 'dsh-soul: workspace index')
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// 装配时发现「有 sessionId 但索引查不到」的次数。由 section.text 递增(同步、无副作用),
|
|
2320
|
+
// 由上面的定时器消费。刻意用计数器而不是 Set:装配是每轮都跑的,Set 会无限增长。
|
|
2321
|
+
let pendingWorkspaceRescan = 0
|
|
2322
|
+
|
|
2323
|
+
// ==================== 工作区路由 ====================
|
|
2324
|
+
|
|
2325
|
+
// 工作区覆盖的读写。与绑定路由**刻意分开**:绑定是独立的 soul-bindings.json,
|
|
2326
|
+
// 工作区覆盖是 soul-config.json 的一个字段 —— 后者必须走配置写队列与审计。
|
|
2327
|
+
function registerWorkspaceRoutes(ctx) {
|
|
2328
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
2329
|
+
const send = (res, status, body) => {
|
|
2330
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
2331
|
+
res.end(JSON.stringify(body))
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// 一次性读全:宿主的工作区列表 + 当前配置里的覆盖表 + 每个工作区的解析结果。
|
|
2335
|
+
//
|
|
2336
|
+
// 全部由**宿主**算,设置页/详情页只负责画 —— 它们拿不到 workspaceRegistry
|
|
2337
|
+
// (客户端侧是 `workspaces` 服务,是另一份投影)。
|
|
2338
|
+
const workspaceView = async () => {
|
|
2339
|
+
const config = await loadConfig()
|
|
2340
|
+
const registry = ctx.get('workspaceRegistry')
|
|
2341
|
+
const scopes = workspaceScopesOf(config)
|
|
2342
|
+
|
|
2343
|
+
const workspaces = []
|
|
2344
|
+
let indexProblem = null
|
|
2345
|
+
if (!registry || typeof registry.list !== 'function') {
|
|
2346
|
+
indexProblem = 'workspaceRegistry 服务不可用:无法列出宿主的工作区。'
|
|
2347
|
+
} else {
|
|
2348
|
+
let listed = []
|
|
2349
|
+
try {
|
|
2350
|
+
listed = registry.list()
|
|
2351
|
+
} catch (err) {
|
|
2352
|
+
indexProblem = `列出工作区失败:${String((err && err.message) || err)}`
|
|
2353
|
+
}
|
|
2354
|
+
// 失效判断必须用**合并后的预设库**(0.9.0-v):内置预设不在 config.personas 里
|
|
2355
|
+
// (它们只存在于代码),若只查磁盘条目,绑定指向内置预设的会被误报
|
|
2356
|
+
// 「人设已不存在」—— 实测「自驱型协作者」「头脑风暴伙伴」都中招。
|
|
2357
|
+
const personaNames = mergePersonas(config.personas)
|
|
2358
|
+
for (const workspace of Array.isArray(listed) ? listed : []) {
|
|
2359
|
+
const id = workspace && workspace.id !== undefined ? String(workspace.id) : null
|
|
2360
|
+
if (id === null) continue
|
|
2361
|
+
const override = resolveWorkspaceOverride(id, scopes)
|
|
2362
|
+
// 失效引用:覆盖指向的人设已不存在。**如实标出但不自动删除** ——
|
|
2363
|
+
// 用户可能只是临时改名,删掉就再也找不回来了(与绑定表同一条约定)。
|
|
2364
|
+
// 引用字段说明「当初绑的是谁」:预设被改名/删除后,覆盖的字段值仍在生效,
|
|
2365
|
+
// 但界面必须能说出这条覆盖已经指向空气 —— 否则用户看到一堆陌生字段无从判断。
|
|
2366
|
+
const refName = override && typeof override[WORKSPACE_PERSONA_REF] === 'string'
|
|
2367
|
+
? override[WORKSPACE_PERSONA_REF]
|
|
2368
|
+
: null
|
|
2369
|
+
const invalidPersona = refName !== null && !(refName in personaNames)
|
|
2370
|
+
workspaces.push({
|
|
2371
|
+
workspaceId: id,
|
|
2372
|
+
title: typeof workspace.title === 'string' ? workspace.title : '',
|
|
2373
|
+
path: typeof workspace.path === 'string' ? workspace.path : '',
|
|
2374
|
+
sessionCount: Array.isArray(workspace.sessionIds) ? workspace.sessionIds.length : 0,
|
|
2375
|
+
bound: override !== null,
|
|
2376
|
+
// 绑的是哪个预设:界面要显示它,而不是只说"已绑定"。
|
|
2377
|
+
// 失效时仍原样返回 —— 用户需要看到"它原本指向谁"才能判断怎么处理。
|
|
2378
|
+
persona: refName,
|
|
2379
|
+
invalidPersona
|
|
2380
|
+
})
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
return {
|
|
2385
|
+
workspaces,
|
|
2386
|
+
indexProblem,
|
|
2387
|
+
max: MAX_WORKSPACE_SCOPES,
|
|
2388
|
+
// 供 UI 展示「当前有几条覆盖」。数字取自配置而非上面的列表:
|
|
2389
|
+
// 覆盖可以指向一个已被删除的工作区,那种条目仍占着表。
|
|
2390
|
+
boundCount: Object.keys(scopes).length
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
wsCtx.webServer.register({
|
|
2395
|
+
kind: 'exact',
|
|
2396
|
+
path: '/api/soul/workspaces',
|
|
2397
|
+
handler: async (req, res) => {
|
|
2398
|
+
if (req.method === 'GET') {
|
|
2399
|
+
try {
|
|
2400
|
+
send(res, 200, { ok: true, ...(await workspaceView()) })
|
|
2401
|
+
} catch (err) {
|
|
2402
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2403
|
+
}
|
|
2404
|
+
return
|
|
2405
|
+
}
|
|
2406
|
+
if (req.method !== 'POST') {
|
|
2407
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2408
|
+
return
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
try {
|
|
2412
|
+
const parsed = await readJsonBody(req)
|
|
2413
|
+
if (!parsed.ok) {
|
|
2414
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2415
|
+
return
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
const config = await loadConfig()
|
|
2419
|
+
const verdict = validateWorkspaceBinding(parsed.body, config.personas)
|
|
2420
|
+
if (!verdict.ok) {
|
|
2421
|
+
send(res, 400, { ok: false, error: verdict.error })
|
|
2422
|
+
return
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// 走配置写队列:与 HTTP 保存 / 命令 / 工具共用同一条串行路径,
|
|
2426
|
+
// 并自动获得变更审计(source = web, action = save)。
|
|
2427
|
+
await commitConfig((current) => {
|
|
2428
|
+
const scopes = workspaceScopesOf(current)
|
|
2429
|
+
scopes[verdict.workspaceId] = verdict.override
|
|
2430
|
+
const next = { ...current, workspaces: { version: WORKSPACE_SCOPE_VERSION, scopes } }
|
|
2431
|
+
return next
|
|
2432
|
+
}, { source: 'web', action: 'save' })
|
|
2433
|
+
|
|
2434
|
+
// 工作区改的是**人设**,必须重算提示词并注入 —— commitConfig 的
|
|
2435
|
+
// promptChanged 看不见 workspaces(它不在 AUDIT_FIELDS / PROMPT_INPUT_FIELDS 里)。
|
|
2436
|
+
const fresh = await loadConfig()
|
|
2437
|
+
refreshPromptAndInject(ctx, fresh)
|
|
2438
|
+
|
|
2439
|
+
send(res, 200, { ok: true, ...(await workspaceView()) })
|
|
2440
|
+
} catch (err) {
|
|
2441
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
})
|
|
2445
|
+
|
|
2446
|
+
// 解绑:把某个工作区从覆盖表里删掉。与绑定路由的 /delete 同一形态。
|
|
2447
|
+
wsCtx.webServer.register({
|
|
2448
|
+
kind: 'exact',
|
|
2449
|
+
path: '/api/soul/workspaces/delete',
|
|
2450
|
+
handler: async (req, res) => {
|
|
2451
|
+
if (req.method !== 'POST') {
|
|
2452
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2453
|
+
return
|
|
2454
|
+
}
|
|
2455
|
+
try {
|
|
2456
|
+
const parsed = await readJsonBody(req)
|
|
2457
|
+
if (!parsed.ok) {
|
|
2458
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2459
|
+
return
|
|
2460
|
+
}
|
|
2461
|
+
const workspaceId = parsed.body && parsed.body.workspaceId
|
|
2462
|
+
if (typeof workspaceId !== 'string' || workspaceId.trim() === '') {
|
|
2463
|
+
send(res, 400, { ok: false, error: '缺少 workspaceId' })
|
|
2464
|
+
return
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
let removed = false
|
|
2468
|
+
await commitConfig((current) => {
|
|
2469
|
+
const scopes = workspaceScopesOf(current)
|
|
2470
|
+
if (!(workspaceId in scopes)) return current
|
|
2471
|
+
removed = true
|
|
2472
|
+
const next = { ...scopes }
|
|
2473
|
+
delete next[workspaceId]
|
|
2474
|
+
// 删空后把字段整个去掉:留一个 `{ version, scopes: {} }` 会让「从未设过」
|
|
2475
|
+
// 与「设过又全删了」在磁盘上不可区分,排查时看不出真相。
|
|
2476
|
+
const empty = Object.keys(next).length === 0
|
|
2477
|
+
return { ...current, workspaces: empty ? undefined : { version: WORKSPACE_SCOPE_VERSION, scopes: next } }
|
|
2478
|
+
}, { source: 'web', action: 'save' })
|
|
2479
|
+
|
|
2480
|
+
if (removed) {
|
|
2481
|
+
const fresh = await loadConfig()
|
|
2482
|
+
refreshPromptAndInject(ctx, fresh)
|
|
2483
|
+
}
|
|
2484
|
+
send(res, 200, { ok: true, removed, ...(await workspaceView()) })
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
})
|
|
2490
|
+
})
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// ==================== 会话级覆盖路由(0.9.0-c)====================
|
|
2494
|
+
|
|
2495
|
+
// 会话覆盖的读写 + 回收。三条路由:
|
|
2496
|
+
// GET /api/soul/session-scope?sessionId=... 读该会话的覆盖与解析结果
|
|
2497
|
+
// POST /api/soul/session-scope 设置 / 清除该会话的覆盖
|
|
2498
|
+
// POST /api/soul/session-scope/gc 回收(默认 dry-run)
|
|
2499
|
+
//
|
|
2500
|
+
// 与工作区覆盖的区别:工作区写**配置**(走 commitConfig、进审计、有变更历史);
|
|
2501
|
+
// 会话写**状态**(sidecar、不进审计)。这不是疏漏:「改了这次会话用什么人设」是
|
|
2502
|
+
// 一次性的临时动作,记进变更历史只会把时间线淹没。
|
|
2503
|
+
function registerSessionScopeRoutes(ctx) {
|
|
2504
|
+
ctx.inject(['webServer'], (wsCtx) => {
|
|
2505
|
+
const send = (res, status, body) => {
|
|
2506
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
2507
|
+
res.end(JSON.stringify(body))
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// 会话覆盖的「当前值 + 解析结果」,供会话内 tab 显示。
|
|
2511
|
+
//
|
|
2512
|
+
// 全部在宿主算:客户端拿不到 sessionId → workspaceId 的权威映射(那是宿主侧索引)。
|
|
2513
|
+
const sessionScopeView = async (sessionId) => {
|
|
2514
|
+
const config = await loadConfig()
|
|
2515
|
+
const override = await loadSessionOverride(sessionId)
|
|
2516
|
+
const workspaceId = sessionWorkspaceIndex.get(String(sessionId)) || null
|
|
2517
|
+
const resolved = configForScope(config, null, workspaceId, override)
|
|
2518
|
+
|
|
2519
|
+
// 生效人设名:走与真实装配**同一条路径**,否则 tab 上显示的与模型拿到的不一致。
|
|
2520
|
+
//
|
|
2521
|
+
// ⚠️ 但「按字段反查」是不确定的:多个预设声明相同字段时,名称排序决定谁命中
|
|
2522
|
+
// (实测:自建「游戏策划与开发专家」与内置「自驱型协作者」字段一致时,
|
|
2523
|
+
// 用户点了后者却显示前者)。所以**先看 sidecar 里记的 personaName** ——
|
|
2524
|
+
// 那是用户实际点过的名字,确定无疑;没有它(老 sidecar / 手改字段)才退回反查。
|
|
2525
|
+
const recordedName = await recordedSessionPersonaName(sessionId)
|
|
2526
|
+
const activePersona = recordedName
|
|
2527
|
+
|| resolveActivePersonaName(resolved.config)
|
|
2528
|
+
return {
|
|
2529
|
+
sessionId,
|
|
2530
|
+
workspaceId,
|
|
2531
|
+
override: override || null,
|
|
2532
|
+
source: resolved.source,
|
|
2533
|
+
activePersona,
|
|
2534
|
+
// 用户实际点的那个名字(可能与「按字段反查」的结论不同)。
|
|
2535
|
+
// 客户端据此显示「你点的是 A,但字段与 B 相同」这类提示。
|
|
2536
|
+
recordedPersona: recordedName,
|
|
2537
|
+
// 全局层的人设名:客户端要显示「当前是谁」与「全局是谁」两者的关系
|
|
2538
|
+
// (会话覆盖生效时,全局值仍在,但当前值不是它)。
|
|
2539
|
+
globalPersona: resolveActivePersonaName(config),
|
|
2540
|
+
workspaceTitle: (() => {
|
|
2541
|
+
if (workspaceId === null) return null
|
|
2542
|
+
const registry = ctx.get('workspaceRegistry')
|
|
2543
|
+
if (!registry || typeof registry.list !== 'function') return null
|
|
2544
|
+
try {
|
|
2545
|
+
const hit = registry.list().find((w) => String(w.id) === workspaceId)
|
|
2546
|
+
return hit && typeof hit.title === 'string' ? hit.title : null
|
|
2547
|
+
} catch {
|
|
2548
|
+
return null
|
|
2549
|
+
}
|
|
2550
|
+
})()
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
wsCtx.webServer.register({
|
|
2555
|
+
kind: 'exact',
|
|
2556
|
+
path: '/api/soul/session-scope',
|
|
2557
|
+
handler: async (req, res) => {
|
|
2558
|
+
try {
|
|
2559
|
+
if (req.method === 'GET') {
|
|
2560
|
+
const url = new URL(req.url, 'http://localhost')
|
|
2561
|
+
const sessionId = url.searchParams.get('sessionId') || ''
|
|
2562
|
+
if (!isSessionId(sessionId)) {
|
|
2563
|
+
send(res, 400, { ok: false, error: '缺少或非法的 sessionId' })
|
|
2564
|
+
return
|
|
2565
|
+
}
|
|
2566
|
+
send(res, 200, { ok: true, ...(await sessionScopeView(sessionId)) })
|
|
2567
|
+
return
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
if (req.method !== 'POST') {
|
|
2571
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2572
|
+
return
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
const parsed = await readJsonBody(req)
|
|
2576
|
+
if (!parsed.ok) {
|
|
2577
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2578
|
+
return
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
const sessionId = parsed.body && parsed.body.sessionId
|
|
2582
|
+
if (!isSessionId(sessionId)) {
|
|
2583
|
+
send(res, 400, { ok: false, error: '缺少或非法的 sessionId' })
|
|
2584
|
+
return
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
const filePath = sessionScopePathFor(configPath(), sessionId)
|
|
2588
|
+
|
|
2589
|
+
// `clear: true` = 清除覆盖。显式动作而非"传空对象",因为空对象与
|
|
2590
|
+
// "只想改一个字段、其余保留"在语义上无法区分。
|
|
2591
|
+
if (parsed.body && parsed.body.clear === true) {
|
|
2592
|
+
// 删文件:用 quarantine 而不是 rm?—— 这里**必须是真删**:
|
|
2593
|
+
// 用户主动清除覆盖,把一个 .bad 文件留在磁盘上只会污染 GC 的清单。
|
|
2594
|
+
// 但删除失败要如实报告,不能假装成功。
|
|
2595
|
+
let removed = false
|
|
2596
|
+
try {
|
|
2597
|
+
const { rm } = await import('node:fs/promises')
|
|
2598
|
+
await rm(filePath, { force: true })
|
|
2599
|
+
removed = true
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
send(res, 500, { ok: false, error: `清除失败:${String((err && err.message) || err)}` })
|
|
2602
|
+
return
|
|
2603
|
+
}
|
|
2604
|
+
forgetSessionOverride(sessionId)
|
|
2605
|
+
if (removed) refreshPromptAndInject(ctx, await loadConfig())
|
|
2606
|
+
send(res, 200, { ok: true, ...(await sessionScopeView(sessionId)) })
|
|
2607
|
+
return
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// 设置覆盖:白名单过滤,enabled 等包封字段一律丢弃。
|
|
2611
|
+
const { override, dropped } = sanitizeSessionOverride(parsed.body && parsed.body.overrides)
|
|
2612
|
+
if (override === null) {
|
|
2613
|
+
send(res, 400, {
|
|
2614
|
+
ok: false,
|
|
2615
|
+
error: dropped.length > 0
|
|
2616
|
+
? `没有可用的覆盖字段(被忽略:${dropped.join(', ')})`
|
|
2617
|
+
: '缺少 overrides'
|
|
2618
|
+
})
|
|
2619
|
+
return
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// 保留原有的 createdAt 与孤儿标记:重写覆盖不等于"这个会话是新出现的"。
|
|
2623
|
+
const previous = await readSessionScope(filePath, sessionId)
|
|
2624
|
+
const now = Date.now()
|
|
2625
|
+
// personaName:客户端告诉服务端"用户实际点了哪个预设"。
|
|
2626
|
+
// 覆盖项只存字段,而「当前生效的是哪个预设」若靠字段反查,在两个预设字段
|
|
2627
|
+
// 相同时会不确定(实测:自建「游戏策划与开发专家」与内置「自驱型协作者」
|
|
2628
|
+
// 字段一致时,名称排序让自建的那个抢先命中,用户点了 A 却显示 B)。
|
|
2629
|
+
// 记下名字就把"猜"变成"知道"。
|
|
2630
|
+
const requestedName = parsed.body && typeof parsed.body.personaName === 'string'
|
|
2631
|
+
? parsed.body.personaName.trim()
|
|
2632
|
+
: ''
|
|
2633
|
+
const doc = {
|
|
2634
|
+
...createSessionScopeDoc(
|
|
2635
|
+
sessionId,
|
|
2636
|
+
override,
|
|
2637
|
+
now,
|
|
2638
|
+
requestedName || (previous.personaName ?? null)
|
|
2639
|
+
),
|
|
2640
|
+
createdAt: (previous.doc && previous.doc.createdAt) || new Date(now).toISOString(),
|
|
2641
|
+
...(previous.orphan ? { orphan: previous.orphan } : {})
|
|
2642
|
+
}
|
|
2643
|
+
await writeSessionScope(filePath, doc)
|
|
2644
|
+
|
|
2645
|
+
// **先写缓存再刷新提示词**:缓存更新是同步的,保证紧接着的那一轮装配
|
|
2646
|
+
// 一定能看到新值。若等磁盘异步完成再刷,装配可能先跑、读到旧缓存。
|
|
2647
|
+
sessionOverrides.set(sessionId, override)
|
|
2648
|
+
sessionOverridesKnown.add(sessionId)
|
|
2649
|
+
// personaName 也同步进缓存:紧接着的 sessionScopeView 立刻能读到,
|
|
2650
|
+
// 不必等下一次读盘。
|
|
2651
|
+
if (requestedName) sessionPersonaNames.set(sessionId, requestedName)
|
|
2652
|
+
else sessionPersonaNames.delete(sessionId)
|
|
2653
|
+
refreshPromptAndInject(ctx, await loadConfig())
|
|
2654
|
+
|
|
2655
|
+
send(res, 200, { ok: true, dropped, ...(await sessionScopeView(sessionId)) })
|
|
2656
|
+
} catch (err) {
|
|
2657
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
})
|
|
2661
|
+
|
|
2662
|
+
// —— 当前会话的作用域(供输入框下方的标签显示)——
|
|
2663
|
+
//
|
|
2664
|
+
// 为什么需要它:全局 store 里的 activePersona 是**全局**匹配结果,
|
|
2665
|
+
// 而三级隔离下会话可能有自己的覆盖或工作区绑定 —— 两者可以不同。
|
|
2666
|
+
// 标签若显示全局值,就会出现「界面说 A、模型用 B」的割裂(实测踩过)。
|
|
2667
|
+
//
|
|
2668
|
+
// 这个端点**按 query 传 sessionId**:输入框左侧组件(conversation.input.left)
|
|
2669
|
+
// 拿得到当前会话 id(它挂在会话里),而 settings.section 拿不到。
|
|
2670
|
+
wsCtx.webServer.register({
|
|
2671
|
+
kind: 'exact',
|
|
2672
|
+
path: '/api/soul/session-scope/current',
|
|
2673
|
+
handler: async (req, res) => {
|
|
2674
|
+
if (req.method !== 'GET') {
|
|
2675
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2676
|
+
return
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
const url = new URL(req.url, 'http://localhost')
|
|
2680
|
+
const sessionId = url.searchParams.get('sessionId') || ''
|
|
2681
|
+
if (!isSessionId(sessionId)) {
|
|
2682
|
+
send(res, 400, { ok: false, error: '缺少或非法的 sessionId' })
|
|
2683
|
+
return
|
|
2684
|
+
}
|
|
2685
|
+
send(res, 200, { ok: true, ...(await sessionScopeView(sessionId)) })
|
|
2686
|
+
} catch (err) {
|
|
2687
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
})
|
|
2691
|
+
|
|
2692
|
+
// —— 回收:默认 dry-run ——
|
|
2693
|
+
//
|
|
2694
|
+
// 判定依据是 `sessionPersistence.list()`(扫磁盘会话文件),**绝不是** `agents.list()`
|
|
2695
|
+
// (那是 live registry,会话切走就消失,用它判定会误删正在使用的覆盖)。
|
|
2696
|
+
wsCtx.webServer.register({
|
|
2697
|
+
kind: 'exact',
|
|
2698
|
+
path: '/api/soul/session-scope/gc',
|
|
2699
|
+
handler: async (req, res) => {
|
|
2700
|
+
if (req.method !== 'POST') {
|
|
2701
|
+
send(res, 405, { ok: false, error: 'Method not allowed' })
|
|
2702
|
+
return
|
|
2703
|
+
}
|
|
2704
|
+
try {
|
|
2705
|
+
const parsed = await readJsonBody(req)
|
|
2706
|
+
if (!parsed.ok) {
|
|
2707
|
+
send(res, parsed.status, { ok: false, error: parsed.error })
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
// 只有显式 apply:true 才会真的删。默认 dry-run。
|
|
2711
|
+
const apply = parsed.body && parsed.body.apply === true
|
|
2712
|
+
|
|
2713
|
+
const plan = await planSessionGc(ctx)
|
|
2714
|
+
const result = { ok: true, dryRun: !apply, ...plan }
|
|
2715
|
+
|
|
2716
|
+
if (apply && plan.collectable.length > 0) {
|
|
2717
|
+
const removed = []
|
|
2718
|
+
for (const item of plan.collectable) {
|
|
2719
|
+
try {
|
|
2720
|
+
const { rm } = await import('node:fs/promises')
|
|
2721
|
+
await rm(item.path, { force: true })
|
|
2722
|
+
forgetSessionOverride(item.sessionId)
|
|
2723
|
+
removed.push(item.sessionId)
|
|
2724
|
+
} catch (err) {
|
|
2725
|
+
result.errors = result.errors || []
|
|
2726
|
+
result.errors.push({ sessionId: item.sessionId, error: String((err && err.message) || err) })
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
result.removed = removed
|
|
2730
|
+
if (removed.length > 0) refreshPromptAndInject(ctx, await loadConfig())
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
send(res, 200, result)
|
|
2734
|
+
} catch (err) {
|
|
2735
|
+
send(res, 500, { ok: false, error: String(err.message || err) })
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
})
|
|
2739
|
+
})
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// 规划一次回收:读全部 sidecar、比对磁盘会话清单、跑状态机。
|
|
2743
|
+
//
|
|
2744
|
+
// 关键:`scanComplete` 反映的是「本轮会话清单是否可信」。拿不到
|
|
2745
|
+
// `sessionPersistence` 时它必须是 false —— 那会让状态机对**所有**条目返回 keep,
|
|
2746
|
+
// 于是「服务不可用」不会演变成「把所有覆盖当孤儿删掉」。
|
|
2747
|
+
async function planSessionGc(ctx) {
|
|
2748
|
+
const dir = sessionScopeDirFor(configPath())
|
|
2749
|
+
const files = await listSessionScopes(dir)
|
|
2750
|
+
|
|
2751
|
+
// 磁盘上的会话清单。
|
|
2752
|
+
let stored = null
|
|
2753
|
+
let persistenceProblem = null
|
|
2754
|
+
const persistence = ctx.get('sessionPersistence')
|
|
2755
|
+
if (!persistence || typeof persistence.list !== 'function') {
|
|
2756
|
+
persistenceProblem = 'sessionPersistence 服务不可用:无法确认哪些会话仍然存在,本轮不做任何判定'
|
|
2757
|
+
} else {
|
|
2758
|
+
try {
|
|
2759
|
+
const listed = await persistence.list()
|
|
2760
|
+
stored = new Set(
|
|
2761
|
+
(Array.isArray(listed) ? listed : [])
|
|
2762
|
+
.map((item) => (item && item.id !== undefined ? String(item.id) : ''))
|
|
2763
|
+
.filter((id) => id !== '')
|
|
2764
|
+
)
|
|
2765
|
+
} catch (err) {
|
|
2766
|
+
persistenceProblem = `列出会话失败:${String((err && err.message) || err)};本轮不做任何判定`
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
const scanComplete = stored !== null && persistenceProblem === null
|
|
2771
|
+
const now = Date.now()
|
|
2772
|
+
|
|
2773
|
+
const entries = []
|
|
2774
|
+
for (const file of files) {
|
|
2775
|
+
const item = {
|
|
2776
|
+
file: file.name,
|
|
2777
|
+
path: file.path,
|
|
2778
|
+
sessionId: null,
|
|
2779
|
+
action: 'keep',
|
|
2780
|
+
reason: '',
|
|
2781
|
+
graceDaysLeft: null,
|
|
2782
|
+
failure: null
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
// 从内容里读 sessionId(文件名是哈希,无法反查)。读不出就只报告,不动它。
|
|
2786
|
+
let doc
|
|
2787
|
+
try {
|
|
2788
|
+
doc = JSON.parse(await (await import('node:fs/promises')).readFile(file.path, 'utf8'))
|
|
2789
|
+
} catch (err) {
|
|
2790
|
+
item.action = 'quarantine'
|
|
2791
|
+
item.reason = `sidecar 读不出来或不是合法 JSON:${String((err && err.message) || err)}`
|
|
2792
|
+
entries.push(item)
|
|
2793
|
+
continue
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
const sessionId = doc && typeof doc.sessionId === 'string' ? doc.sessionId : null
|
|
2797
|
+
if (!isSessionId(sessionId)) {
|
|
2798
|
+
item.action = 'quarantine'
|
|
2799
|
+
item.reason = 'sidecar 内缺少合法的 sessionId(无法与文件名互相校验)'
|
|
2800
|
+
entries.push(item)
|
|
2801
|
+
continue
|
|
2802
|
+
}
|
|
2803
|
+
item.sessionId = sessionId
|
|
2804
|
+
|
|
2805
|
+
// 内容与文件名必须互相对应:改个文件名就能让某个会话读到另一个会话的覆盖。
|
|
2806
|
+
const expectedName = sessionScopePathFor(configPath(), sessionId)
|
|
2807
|
+
if (expectedName !== file.path) {
|
|
2808
|
+
item.action = 'quarantine'
|
|
2809
|
+
item.reason = `文件名与内容里的 sessionId 不符(期望 ${expectedName.split(/[\\/]/).pop()})`
|
|
2810
|
+
entries.push(item)
|
|
2811
|
+
continue
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
const parsedDoc = (await readSessionScope(file.path, sessionId))
|
|
2815
|
+
if (parsedDoc.failure) {
|
|
2816
|
+
item.action = 'quarantine'
|
|
2817
|
+
item.reason = parsedDoc.failure
|
|
2818
|
+
entries.push(item)
|
|
2819
|
+
continue
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
const decision = decideSessionScopeFate({
|
|
2823
|
+
orphan: parsedDoc.orphan,
|
|
2824
|
+
presentOnDisk: scanComplete ? stored.has(sessionId) : false,
|
|
2825
|
+
scanComplete,
|
|
2826
|
+
now
|
|
2827
|
+
})
|
|
2828
|
+
item.action = decision.action
|
|
2829
|
+
item.reason = decision.reason
|
|
2830
|
+
if (decision.orphan && decision.action === 'keep') {
|
|
2831
|
+
const age = now - Date.parse(decision.orphan.firstSeenAt)
|
|
2832
|
+
item.graceDaysLeft = Math.max(0, Math.ceil((ORPHAN_GRACE_MS - age) / (24 * 3600 * 1000)))
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
// 标记 / 撤销标记都要落盘 —— 但**只在扫描完整时**。扫描不完整时状态机返回 keep,
|
|
2836
|
+
// 那时不该写任何东西(否则一次临时的服务故障会把所有条目刷上已扫描时间)。
|
|
2837
|
+
if (decision.action === 'mark' || decision.action === 'clear-mark') {
|
|
2838
|
+
try {
|
|
2839
|
+
const nextDoc = decision.action === 'clear-mark'
|
|
2840
|
+
? { ...doc, orphan: undefined }
|
|
2841
|
+
: { ...doc, orphan: decision.orphan }
|
|
2842
|
+
if (decision.action === 'clear-mark') delete nextDoc.orphan
|
|
2843
|
+
await writeSessionScope(file.path, nextDoc)
|
|
2844
|
+
} catch (err) {
|
|
2845
|
+
item.markError = String((err && err.message) || err)
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
entries.push(item)
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
return {
|
|
2853
|
+
scanComplete,
|
|
2854
|
+
persistenceProblem,
|
|
2855
|
+
total: entries.length,
|
|
2856
|
+
markable: entries.filter((e) => e.action === 'mark'),
|
|
2857
|
+
collectable: entries.filter((e) => e.action === 'collect'),
|
|
2858
|
+
quarantine: entries.filter((e) => e.action === 'quarantine'),
|
|
2859
|
+
entries
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// ==================== 斜杠命令 ====================
|
|
2864
|
+
|
|
2865
|
+
// 宿主端无 locale 服务(CommandInvocation 不携带 UI 语言,settings 中亦无语言字段),
|
|
2866
|
+
// 命令输出语言跟随 soul 配置的 language 字段,可在设置页切换。
|
|
2867
|
+
const COMMAND_MESSAGES = {
|
|
2868
|
+
zh: {
|
|
2869
|
+
showTitle: '🧠 个性化设置',
|
|
2870
|
+
colon: ':',
|
|
2871
|
+
statusLabel: '状态',
|
|
2872
|
+
nicknameLabel: '昵称',
|
|
2873
|
+
occupationLabel: '职业',
|
|
2874
|
+
bioLabel: '介绍',
|
|
2875
|
+
styleLabel: '风格语调',
|
|
2876
|
+
styleNames: {
|
|
2877
|
+
professional: '专业严谨',
|
|
2878
|
+
casual: '轻松自然',
|
|
2879
|
+
humorous: '幽默风趣',
|
|
2880
|
+
roast: '吐槽达人',
|
|
2881
|
+
efficient: '高效干练'
|
|
2882
|
+
},
|
|
2883
|
+
traitsLabel: '特质',
|
|
2884
|
+
headingListsLabel: '标题和列表',
|
|
2885
|
+
emojiLabel: '表情符号',
|
|
2886
|
+
tablesLabel: '表格',
|
|
2887
|
+
replyLengthLabel: '回复长度',
|
|
2888
|
+
traitNames: {
|
|
2889
|
+
default: '默认',
|
|
2890
|
+
more: '增强',
|
|
2891
|
+
less: '减弱'
|
|
2892
|
+
},
|
|
2893
|
+
replyLengthNames: {
|
|
2894
|
+
concise: '简洁',
|
|
2895
|
+
normal: '适中',
|
|
2896
|
+
detailed: '详尽'
|
|
2897
|
+
},
|
|
2898
|
+
listSep: ',',
|
|
2899
|
+
trailLabel: '输入框光轨',
|
|
2900
|
+
trailLine: (status, color, speedName, widthName) => `${status}(${color} / ${speedName} / ${widthName})`,
|
|
2901
|
+
trailSpeedNames: {
|
|
2902
|
+
slow: '慢',
|
|
2903
|
+
normal: '中',
|
|
2904
|
+
fast: '快'
|
|
2905
|
+
},
|
|
2906
|
+
trailWidthNames: {
|
|
2907
|
+
thin: '细',
|
|
2908
|
+
normal: '中',
|
|
2909
|
+
thick: '粗'
|
|
2910
|
+
},
|
|
2911
|
+
instructionsLabel: '自定义指令',
|
|
2912
|
+
enabled: '已启用',
|
|
2913
|
+
disabled: '已禁用',
|
|
2914
|
+
notSet: '未设置',
|
|
2915
|
+
toolConfirmLabel: '确认模式',
|
|
2916
|
+
onLabel: '开启',
|
|
2917
|
+
offLabel: '关闭',
|
|
2918
|
+
personasLabel: '人设预设',
|
|
2919
|
+
// 提议是**内存态**(重启即失效)、也没有 TTL,所以必须让用户看到它是什么时候提出的,
|
|
2920
|
+
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
2921
|
+
pendingHint: (at) => `⚠ 有待确认的人设变更(提出于 ${at}):/soul confirm 确认,/soul reject 拒绝`,
|
|
2922
|
+
noPending: '没有待确认的人设变更',
|
|
2923
|
+
// 会话覆盖回收报告。
|
|
2924
|
+
//
|
|
2925
|
+
// 三种情形要给三种不同的说法,否则用户会误判:
|
|
2926
|
+
// · 扫描不完整(拿不到 sessionPersistence)→ 说清"没做任何判定",并解释为什么;
|
|
2927
|
+
// · dry-run → 列出**将删**的清单,并告诉用户怎么真删;
|
|
2928
|
+
// · apply → 只报结果。
|
|
2929
|
+
// 无论哪种,"没有被判定的条目"都要能从上一次输出里看出来。
|
|
2930
|
+
sessionsGcReport: (plan, apply) => {
|
|
2931
|
+
const lines = []
|
|
2932
|
+
lines.push(apply ? '🧹 会话覆盖清理' : '🧹 会话覆盖清理(预演,未删除任何文件)')
|
|
2933
|
+
lines.push(plan.scanComplete
|
|
2934
|
+
? '会话清单:完整'
|
|
2935
|
+
: `会话清单:不可用 —— ${plan.persistenceProblem || '原因未知'}`)
|
|
2936
|
+
lines.push(`共 ${plan.total} 个文件 · 待观察 ${plan.markable.length} 个 · 可回收 ${plan.collectable.length} 个 · 损坏 ${plan.quarantine.length} 个`)
|
|
2937
|
+
if (!plan.scanComplete) {
|
|
2938
|
+
lines.push('会话清单不可用时不会标记也不会删除 —— 「没看到」不等于「已删除」。')
|
|
2939
|
+
}
|
|
2940
|
+
for (const item of plan.collectable.slice(0, 20)) {
|
|
2941
|
+
lines.push(` ✕ ${item.sessionId} —— ${item.reason}`)
|
|
2942
|
+
}
|
|
2943
|
+
for (const item of plan.quarantine.slice(0, 20)) {
|
|
2944
|
+
lines.push(` ⚠ ${item.file} —— ${item.reason}`)
|
|
2945
|
+
}
|
|
2946
|
+
if (!apply && plan.collectable.length > 0) {
|
|
2947
|
+
lines.push('执行 /soul sessions --apply 可删除上面列出的条目。')
|
|
2948
|
+
}
|
|
2949
|
+
return lines.join('\n')
|
|
2950
|
+
},
|
|
2951
|
+
confirmDone: (detail) => `✅ 已应用人设变更:${detail}`,
|
|
2952
|
+
rejectDone: '✅ 已拒绝待确认的人设变更',
|
|
2953
|
+
saveDone: (name) => `✅ 已保存预设「${name}」`,
|
|
2954
|
+
// 内置预设(随插件发布、不落盘):不可用同名保存,也不可删除
|
|
2955
|
+
builtinName: '内置',
|
|
2956
|
+
builtinSaveBlocked: (name) => `「${name}」是内置预设,请换一个名称`,
|
|
2957
|
+
duplicateSaveBlocked: (names) => `已存在内容完全相同的预设「${names}」,请直接使用它,或先修改人设再保存`,
|
|
2958
|
+
builtinDeleteBlocked: (name) => `「${name}」是内置预设,无法删除`,
|
|
2959
|
+
useDone: (name) => `✅ 已应用预设「${name}」`,
|
|
2960
|
+
useUnchanged: (name) => `ℹ 预设「${name}」与当前配置一致,无需变更`,
|
|
2961
|
+
// 预设里部分字段的取值非法(历史值或手改):合法字段照常应用,被跳过的必须说出来
|
|
2962
|
+
usePartial: (name, fields) => `⚠ 预设「${name}」已部分应用,以下字段取值非法、已跳过:${fields}`,
|
|
2963
|
+
useAllInvalid: (name, fields) => `✗ 预设「${name}」没有可应用的合法字段:${fields}`,
|
|
2964
|
+
personaMissing: (name) => `预设「${name}」不存在`,
|
|
2965
|
+
delDone: (name) => `✅ 已删除预设「${name}」`,
|
|
2966
|
+
listTitle: '📋 人设预设',
|
|
2967
|
+
listEmpty: '暂无人设预设,使用 /soul save <名称> 保存当前配置',
|
|
2968
|
+
// 列表行报告预设实际覆盖的人格维度数量(取代 v0.7.1 起恒为空的「昵称」列)
|
|
2969
|
+
listCovers: (n) => `覆盖 ${n} 项`,
|
|
2970
|
+
saveUsage: '用法:/soul save <名称>(1-30 个字符)',
|
|
2971
|
+
useUsage: '用法:/soul use <名称>',
|
|
2972
|
+
delUsage: '用法:/soul del <名称>',
|
|
2973
|
+
setUsage: '用法:/soul set key=value ...(可用字段:enabled / style / headingLists / emoji / tables / replyLength / language / nickname / occupation / bio / customInstructions / trailEnabled / trailColor / trailSpeed / trailWidth)',
|
|
2974
|
+
unknownField: '未知配置项',
|
|
2975
|
+
invalidBoolean: (key, value) => `${key} 取值必须为 true / false(收到:${JSON.stringify(value)})`,
|
|
2976
|
+
setNoChanges: '配置无变化,未做修改',
|
|
1217
2977
|
setDone: (keys) => `✅ 已更新:${keys.join('、')}`,
|
|
1218
2978
|
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 设置昵称',
|
|
1219
2979
|
resetDone: '✅ 配置已重置为默认值',
|
|
@@ -1284,11 +3044,34 @@ const COMMAND_MESSAGES = {
|
|
|
1284
3044
|
// 否则一条陈旧提议会一直挂着而无法判断新鲜度(proposedAt 此前只写不读)。
|
|
1285
3045
|
pendingHint: (at) => `⚠ Pending persona proposal (raised at ${at}): /soul confirm to apply, /soul reject to discard`,
|
|
1286
3046
|
noPending: 'No pending persona proposal',
|
|
3047
|
+
// 会话覆盖回收报告。格式与中文版逐项对应(两边都由同一次 planSessionGc 驱动)。
|
|
3048
|
+
sessionsGcReport: (plan, apply) => {
|
|
3049
|
+
const lines = []
|
|
3050
|
+
lines.push(apply ? '🧹 Session-scope cleanup' : '🧹 Session-scope cleanup (dry run — nothing deleted)')
|
|
3051
|
+
lines.push(plan.scanComplete
|
|
3052
|
+
? 'Session list: complete'
|
|
3053
|
+
: `Session list: UNAVAILABLE — ${plan.persistenceProblem || 'unknown reason'}`)
|
|
3054
|
+
lines.push(`Files: ${plan.total} · candidates: ${plan.markable.length} · collectable: ${plan.collectable.length} · corrupt: ${plan.quarantine.length}`)
|
|
3055
|
+
if (!plan.scanComplete) {
|
|
3056
|
+
lines.push('No entry is marked or deleted while the session list is unavailable — "not seen" is not "deleted".')
|
|
3057
|
+
}
|
|
3058
|
+
for (const item of plan.collectable.slice(0, 20)) {
|
|
3059
|
+
lines.push(` ✕ ${item.sessionId} — ${item.reason}`)
|
|
3060
|
+
}
|
|
3061
|
+
for (const item of plan.quarantine.slice(0, 20)) {
|
|
3062
|
+
lines.push(` ⚠ ${item.file} — ${item.reason}`)
|
|
3063
|
+
}
|
|
3064
|
+
if (!apply && plan.collectable.length > 0) {
|
|
3065
|
+
lines.push('Run `/soul sessions --apply` to delete the entries listed above.')
|
|
3066
|
+
}
|
|
3067
|
+
return lines.join('\n')
|
|
3068
|
+
},
|
|
1287
3069
|
confirmDone: (detail) => `✅ Persona changes applied: ${detail}`,
|
|
1288
3070
|
rejectDone: '✅ Pending persona proposal discarded',
|
|
1289
3071
|
saveDone: (name) => `✅ Persona "${name}" saved`,
|
|
1290
3072
|
builtinName: 'built-in',
|
|
1291
3073
|
builtinSaveBlocked: (name) => `"${name}" is a built-in persona — pick another name`,
|
|
3074
|
+
duplicateSaveBlocked: (names) => `A persona with identical content already exists: "${names}" — use it directly, or adjust the persona before saving`,
|
|
1292
3075
|
builtinDeleteBlocked: (name) => `"${name}" is a built-in persona and cannot be deleted`,
|
|
1293
3076
|
useDone: (name) => `✅ Persona "${name}" applied`,
|
|
1294
3077
|
useUnchanged: (name) => `ℹ Persona "${name}" matches the current config`,
|
|
@@ -1330,7 +3113,7 @@ function commandMessages(config) {
|
|
|
1330
3113
|
}
|
|
1331
3114
|
|
|
1332
3115
|
// /soul 子命令关键字(命中走子命令;否则整体视为昵称)
|
|
1333
|
-
const COMMAND_KEYWORDS = new Set(['show', 'reset', 'enable', 'disable', 'save', 'use', 'list', 'del', 'delete', 'rm', 'set', 'confirm', 'reject'])
|
|
3116
|
+
const COMMAND_KEYWORDS = new Set(['show', 'reset', 'enable', 'disable', 'save', 'use', 'list', 'del', 'delete', 'rm', 'set', 'confirm', 'reject', 'sessions'])
|
|
1334
3117
|
|
|
1335
3118
|
// /soul set 中按布尔解析的键;其余键以原始字符串交给 sanitizeConfig 校验
|
|
1336
3119
|
const COMMAND_BOOLEAN_KEYS = new Set(['enabled', 'trailEnabled'])
|
|
@@ -1364,8 +3147,8 @@ function registerCommands(ctx) {
|
|
|
1364
3147
|
ctx.inject(['commands'], (cmdCtx) => {
|
|
1365
3148
|
cmdCtx.commands.register({
|
|
1366
3149
|
name: 'soul',
|
|
1367
|
-
description: '查看或管理个性化设置。用法:/soul [show|set|save|use|list|del|confirm|reject|reset|enable|disable|昵称]',
|
|
1368
|
-
input: { hint: '[show|set k=v|save <名>|use <名>|list|del <名>|confirm|reject|reset|enable|disable|昵称]' },
|
|
3150
|
+
description: '查看或管理个性化设置。用法:/soul [show|set|save|use|list|del|confirm|reject|reset|enable|disable|sessions|昵称]',
|
|
3151
|
+
input: { hint: '[show|set k=v|save <名>|use <名>|list|del <名>|confirm|reject|reset|enable|disable|sessions [--apply]|昵称]' },
|
|
1369
3152
|
async handler(invocation) {
|
|
1370
3153
|
// 保留原始大小写用于昵称/预设名,仅关键字匹配时忽略大小写
|
|
1371
3154
|
const raw = String(invocation.rawInput || '').trim()
|
|
@@ -1383,7 +3166,7 @@ function registerCommands(ctx) {
|
|
|
1383
3166
|
if (errors.nickname) {
|
|
1384
3167
|
return { kind: 'error', text: t.failed(errors.nickname) }
|
|
1385
3168
|
}
|
|
1386
|
-
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, nickname: patch.nickname }))
|
|
3169
|
+
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, nickname: patch.nickname }), { source: 'command', action: 'nickname' })
|
|
1387
3170
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1388
3171
|
return { kind: 'success', text: t.nicknameDone(patch.nickname) }
|
|
1389
3172
|
}
|
|
@@ -1427,7 +3210,7 @@ function registerCommands(ctx) {
|
|
|
1427
3210
|
if (first === 'reset') {
|
|
1428
3211
|
// 重置活动配置,但保留人设预设库。
|
|
1429
3212
|
// 经由逃生口:配置损坏时这是唯一还能落盘的操作(先备份损坏文件)。
|
|
1430
|
-
const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape()
|
|
3213
|
+
const { config: updated, promptChanged, backupPath } = await resetConfigWithEscape({ source: 'command', action: 'reset' })
|
|
1431
3214
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1432
3215
|
return backupPath
|
|
1433
3216
|
? { kind: 'success', text: t.resetRecovered(backupPath) }
|
|
@@ -1436,13 +3219,13 @@ function registerCommands(ctx) {
|
|
|
1436
3219
|
|
|
1437
3220
|
if (first === 'enable') {
|
|
1438
3221
|
// 写队列内「读—改—写」,不在队列外改写内存缓存对象;无变化时跳过注入
|
|
1439
|
-
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: true }))
|
|
3222
|
+
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: true }), { source: 'command', action: 'enable' })
|
|
1440
3223
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1441
3224
|
return { kind: 'success', text: t.enableDone }
|
|
1442
3225
|
}
|
|
1443
3226
|
|
|
1444
3227
|
if (first === 'disable') {
|
|
1445
|
-
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: false }))
|
|
3228
|
+
const { config: updated, promptChanged } = await commitConfig(c => ({ ...c, enabled: false }), { source: 'command', action: 'disable' })
|
|
1446
3229
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1447
3230
|
return { kind: 'success', text: t.disableDone }
|
|
1448
3231
|
}
|
|
@@ -1453,11 +3236,19 @@ function registerCommands(ctx) {
|
|
|
1453
3236
|
if (isBuiltinPersona(personaName)) {
|
|
1454
3237
|
return { kind: 'error', text: t.builtinSaveBlocked(personaName) }
|
|
1455
3238
|
}
|
|
3239
|
+
// 重复门禁(0.9.0-q),与 HTTP 路径同一判据
|
|
3240
|
+
{
|
|
3241
|
+
const current = await loadConfig()
|
|
3242
|
+
const dupes = duplicatePersonaConflict(
|
|
3243
|
+
personaSnapshotOf(current), mergePersonas(current.personas), personaName
|
|
3244
|
+
)
|
|
3245
|
+
if (dupes) return { kind: 'error', text: t.duplicateSaveBlocked(dupes) }
|
|
3246
|
+
}
|
|
1456
3247
|
await commitConfig((current) => {
|
|
1457
3248
|
const personas = clonePersonas(current.personas)
|
|
1458
3249
|
personas[personaName] = { ...personaSnapshotOf(current), updatedAt: new Date().toISOString() }
|
|
1459
3250
|
return { ...current, personas }
|
|
1460
|
-
})
|
|
3251
|
+
}, { source: 'command', action: 'savePreset' })
|
|
1461
3252
|
return { kind: 'success', text: t.saveDone(personaName) }
|
|
1462
3253
|
}
|
|
1463
3254
|
|
|
@@ -1493,7 +3284,7 @@ function registerCommands(ctx) {
|
|
|
1493
3284
|
if (names.length === 0) {
|
|
1494
3285
|
return { kind: 'success', text: `${t.listTitle}\n${t.listEmpty}` }
|
|
1495
3286
|
}
|
|
1496
|
-
const activeName =
|
|
3287
|
+
const activeName = resolveActivePersonaName(config)
|
|
1497
3288
|
const lines = names.map((personaName) => {
|
|
1498
3289
|
const entry = view[personaName]
|
|
1499
3290
|
const styleText = STYLE_VALUES.includes(entry.style) ? (t.styleNames[entry.style] || entry.style) : (entry.style || '-')
|
|
@@ -1521,7 +3312,7 @@ function registerCommands(ctx) {
|
|
|
1521
3312
|
const personas = clonePersonas(current.personas)
|
|
1522
3313
|
delete personas[personaName]
|
|
1523
3314
|
return { ...current, personas }
|
|
1524
|
-
})
|
|
3315
|
+
}, { source: 'command', action: 'deletePreset' })
|
|
1525
3316
|
return { kind: 'success', text: t.delDone(personaName) }
|
|
1526
3317
|
}
|
|
1527
3318
|
|
|
@@ -1555,7 +3346,7 @@ function registerCommands(ctx) {
|
|
|
1555
3346
|
if (Object.keys(patch).length === 0) {
|
|
1556
3347
|
return { kind: 'error', text: t.failed(droppedKeys.length > 0 ? `${t.unknownField}: ${droppedKeys.join(', ')}` : t.setUsage) }
|
|
1557
3348
|
}
|
|
1558
|
-
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }))
|
|
3349
|
+
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...patch }), { source: 'command', action: 'set' })
|
|
1559
3350
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1560
3351
|
return { kind: 'success', text: changed.length > 0 ? t.setDone(changed) : t.setNoChanges }
|
|
1561
3352
|
}
|
|
@@ -1564,7 +3355,7 @@ function registerCommands(ctx) {
|
|
|
1564
3355
|
if (!pendingPersonaProposal) return { kind: 'error', text: t.noPending }
|
|
1565
3356
|
const proposal = pendingPersonaProposal
|
|
1566
3357
|
pendingPersonaProposal = null
|
|
1567
|
-
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...proposal.patch }))
|
|
3358
|
+
const { config: updated, changed, promptChanged } = await commitConfig(c => ({ ...c, ...proposal.patch }), { source: 'command', action: 'confirm' })
|
|
1568
3359
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, updated)
|
|
1569
3360
|
const detail = Object.keys(proposal.patch).map((key) => `${key}=${JSON.stringify(proposal.patch[key])}`).join(', ')
|
|
1570
3361
|
return { kind: 'success', text: t.confirmDone(detail) }
|
|
@@ -1576,6 +3367,20 @@ function registerCommands(ctx) {
|
|
|
1576
3367
|
return { kind: 'success', text: t.rejectDone }
|
|
1577
3368
|
}
|
|
1578
3369
|
|
|
3370
|
+
// —— 会话覆盖回收(0.9.0-f)——
|
|
3371
|
+
//
|
|
3372
|
+
// 刻意收敛成**一个子命令**(`/soul sessions`)而不是散落的 `gc` / `sessions`:
|
|
3373
|
+
// 将来会话覆盖还会有别的运维动作(导出、清空全部),共用一个前缀才不会
|
|
3374
|
+
// 让 `/soul` 的子命令表继续膨胀。
|
|
3375
|
+
//
|
|
3376
|
+
// 默认 dry-run:`/soul sessions` 只列清单,`/soul sessions --apply` 才真删。
|
|
3377
|
+
// 删除是不可逆的,默认值必须站在"不删"那一侧。
|
|
3378
|
+
if (first === 'sessions') {
|
|
3379
|
+
const apply = /\B--apply\b/.test(rest)
|
|
3380
|
+
const plan = await planSessionGc(ctx)
|
|
3381
|
+
return { kind: 'success', text: t.sessionsGcReport(plan, apply) }
|
|
3382
|
+
}
|
|
3383
|
+
|
|
1579
3384
|
// 理论不可达:关键字表与上方分支一一对应
|
|
1580
3385
|
return { kind: 'error', text: t.help }
|
|
1581
3386
|
} catch (err) {
|
|
@@ -1619,7 +3424,35 @@ function registerSystemPrompt(ctx) {
|
|
|
1619
3424
|
// 代价为 0:我们不使用任何宿主提示词变量(需要上下文时,text 本身就是
|
|
1620
3425
|
// provider)。关掉它同时让「预览所见」与「真实注入」逐字符一致。
|
|
1621
3426
|
interpolate: false,
|
|
1622
|
-
text: () =>
|
|
3427
|
+
text: (assembly) => {
|
|
3428
|
+
// ⚠️ 必须同步:宿主以 `section.text(context)` 的形式**同步**调用
|
|
3429
|
+
// (dsh-system-prompt 的装配:`typeof entry.text === 'function' ? entry.text(context) : entry.text`),
|
|
3430
|
+
// 返回 Promise 会被当成 section 文本对象塞进提示词。所以这里只做纯字符串
|
|
3431
|
+
// 比较——路径的身份解析(realpath)已在 loadBindings() 里对**键**做过一次。
|
|
3432
|
+
//
|
|
3433
|
+
// ⚠️ 也必须**只取一次快照**:configCache 由写队列整体替换。若这里分两次读
|
|
3434
|
+
// (例如先 configCache、再在 configForCwd 里又读一遍),装配期间发生写入就会
|
|
3435
|
+
// 让同一轮用上两个版本的配置。快照先取,之后全用它。
|
|
3436
|
+
//
|
|
3437
|
+
// `assembly.agent` 在诊断装配时缺席(宿主注释写明 `absent on diagnostics`),
|
|
3438
|
+
// cwdOfAgent 对 undefined 返回 null ⇒ configForCwd 原样返回全局配置。
|
|
3439
|
+
const snap = snapshotConfig()
|
|
3440
|
+
const agent = assembly && assembly.agent
|
|
3441
|
+
// 快照必须**整体**使用:连同索引一起,本轮只取一次。
|
|
3442
|
+
// sessionId 走索引拿 workspaceId;拿不到就让 configForScope 回退到 cwd。
|
|
3443
|
+
const sessionId = sessionIdOfAgent(agent)
|
|
3444
|
+
const workspaceId = sessionId === null ? null : (sessionWorkspaceIndex.get(sessionId) || null)
|
|
3445
|
+
// 会话层覆盖走**内存缓存**(同步),未命中时它会在后台排一次读盘,
|
|
3446
|
+
// 本轮按上层配置走 —— 下一轮生效。装配里绝不能 await。
|
|
3447
|
+
const sessionOverride = sessionOverridesOrNull(sessionId)
|
|
3448
|
+
const resolved = configForScope(snap.value, cwdOfAgent(agent), workspaceId, sessionOverride)
|
|
3449
|
+
const prompt = compilePrompt(resolved.config)
|
|
3450
|
+
// 记下「这一轮实际用的是哪一版」:排查「改了却没生效」时,第一个要回答的就是它。
|
|
3451
|
+
// 只写不读,不影响任何行为(deliveryState 由状态端点序列化给设置页)。
|
|
3452
|
+
deliveryState.lastAssembledRevision = snap.revision
|
|
3453
|
+
deliveryState.lastAssembledScope = resolved.source
|
|
3454
|
+
return prompt
|
|
3455
|
+
}
|
|
1623
3456
|
})
|
|
1624
3457
|
|
|
1625
3458
|
deliveryState.sectionRegisteredAt = new Date().toISOString()
|
|
@@ -1930,7 +3763,7 @@ function registerTools(ctx) {
|
|
|
1930
3763
|
next[key] = patch[key]
|
|
1931
3764
|
}
|
|
1932
3765
|
return next
|
|
1933
|
-
})
|
|
3766
|
+
}, { source: 'tool', action: 'setPersona' })
|
|
1934
3767
|
|
|
1935
3768
|
if (changed.length === 0) {
|
|
1936
3769
|
return { ok: true, message: promptTextOf(updated).toolNoChanges }
|
|
@@ -1958,6 +3791,13 @@ function registerTools(ctx) {
|
|
|
1958
3791
|
export async function apply(ctx) {
|
|
1959
3792
|
// 加载配置
|
|
1960
3793
|
await loadConfig()
|
|
3794
|
+
|
|
3795
|
+
// 加载工作区绑定表。
|
|
3796
|
+
//
|
|
3797
|
+
// 必须在装配路径之外做完:system prompt section 的 text provider 是**同步**的
|
|
3798
|
+
// (见 registerSystemPrompt 里的说明),不能在里面 await 读盘。这里读一次,
|
|
3799
|
+
// 之后每轮装配只做纯字符串比较。失败不阻塞启动(绑定是可选增强)。
|
|
3800
|
+
await ensureBindings()
|
|
1961
3801
|
|
|
1962
3802
|
// 提供 soulConfig 服务
|
|
1963
3803
|
ctx.provide('soulConfig', {
|
|
@@ -1970,7 +3810,7 @@ export async function apply(ctx) {
|
|
|
1970
3810
|
if (Object.keys(errors).length > 0) {
|
|
1971
3811
|
throw new Error('配置校验失败:' + formatFieldErrors(errors))
|
|
1972
3812
|
}
|
|
1973
|
-
const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }))
|
|
3813
|
+
const { config, promptChanged } = await commitConfig(current => ({ ...current, ...patch }), { source: 'service', action: 'update' })
|
|
1974
3814
|
// 送达与另外三条写路径保持一致:服务调用方(其它插件)改配置后同样应当
|
|
1975
3815
|
// 立即反映到活动会话,否则「改了配置不生效」会从服务这条路再次出现。
|
|
1976
3816
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
@@ -1982,7 +3822,7 @@ export async function apply(ctx) {
|
|
|
1982
3822
|
},
|
|
1983
3823
|
async resetConfig() {
|
|
1984
3824
|
// 与 HTTP / 斜杠命令两条重置入口共用逃生口
|
|
1985
|
-
const { config, promptChanged } = await resetConfigWithEscape()
|
|
3825
|
+
const { config, promptChanged } = await resetConfigWithEscape({ source: 'service', action: 'reset' })
|
|
1986
3826
|
if (promptChanged.length > 0) refreshPromptAndInject(ctx, config)
|
|
1987
3827
|
return config
|
|
1988
3828
|
}
|
|
@@ -1991,9 +3831,14 @@ export async function apply(ctx) {
|
|
|
1991
3831
|
// 注册各模块
|
|
1992
3832
|
registerRoutes(ctx)
|
|
1993
3833
|
registerPersonaRoutes(ctx)
|
|
3834
|
+
registerHistoryRoutes(ctx)
|
|
3835
|
+
registerBindingRoutes(ctx)
|
|
3836
|
+
registerWorkspaceRoutes(ctx)
|
|
3837
|
+
registerSessionScopeRoutes(ctx)
|
|
1994
3838
|
registerCommands(ctx)
|
|
1995
3839
|
registerSystemPrompt(ctx)
|
|
1996
3840
|
registerTools(ctx)
|
|
3841
|
+
registerWorkspaceIndex(ctx)
|
|
1997
3842
|
}
|
|
1998
3843
|
|
|
1999
3844
|
export { name }
|