dsh-recall-plugin 1.7.0 → 2.0.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/lib/config.js CHANGED
@@ -19,18 +19,36 @@ export const Config = Schema.object({
19
19
  gcSnaps: Schema.number().default(50).description('每积累多少条快照触发一次 git gc'),
20
20
  gcHours: Schema.number().default(24).description('距上次 gc 超过多少小时触发(与条数先到先触发)'),
21
21
  maxFileBytes: Schema.number().default(104857600).description('超过该字节数的文件不进快照、不被回退触碰'),
22
+ maxSnapshotsPerWorkspace: Schema.number().default(500).description('每个工作区保留的最大快照数,超限删除最旧的'),
22
23
  // 排除表必须同时覆盖两种存储目录名:降级存储是项目内 .dsh-recall-snapshots/,
23
24
  // 而 home 存储目录名是 dsh-recall-snapshots/(无点)——工作区 root 恰为
24
25
  // HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
25
26
  // 把影子仓库自己吞进去、快照全部失败(issue #6)
26
27
  baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
27
28
  refillDraft: Schema.boolean().default(true).description('撤回后把被撤回的消息文本回填到输入框'),
29
+ snapshotEnabled: Schema.boolean().default(true).description('启用消息快照(关闭后不再新建,已有快照仍可撤回)'),
30
+ archiveOriginal: Schema.boolean().default(true).description('撤回后归档原会话(关闭后原会话保留在列表中)'),
31
+ retentionDays: Schema.number().default(0).description('按天数保留快照,超期自动删除;0 表示不启用'),
28
32
  })
29
33
 
30
34
  // schema 默认值的运行时镜像:settings 服务未组装时 createConfig 直接以
31
- // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)
35
+ // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)。
36
+ // DEFAULTS 同时供 config-reset 降级路径(settings.replace 不可用时的兜底,
37
+ // 见 index.js config-reset 端点)——默认值只此一份,避免重置与 schema 漂移。
32
38
  const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
33
39
 
40
+ export const DEFAULTS = {
41
+ gcSnaps: 50,
42
+ gcHours: 24,
43
+ maxFileBytes: 104857600,
44
+ maxSnapshotsPerWorkspace: 500,
45
+ baseExcludes: BASE_EXCLUDES,
46
+ refillDraft: true,
47
+ snapshotEnabled: true,
48
+ archiveOriginal: true,
49
+ retentionDays: 0,
50
+ }
51
+
34
52
  export function createConfig(raw) {
35
53
  const cfg = raw && typeof raw === 'object' ? raw : {}
36
54
 
@@ -44,6 +62,13 @@ export function createConfig(raw) {
44
62
  const gcSnaps = pickNumber(process.env.DSH_RECALL_GC_SNAPS, pickNumber(cfg.gcSnaps, 50, 1), 1)
45
63
  const gcHours = pickNumber(process.env.DSH_RECALL_GC_HOURS, pickNumber(cfg.gcHours, 24, 1), 1)
46
64
  const maxFileBytes = pickNumber(cfg.maxFileBytes, 104857600, 1024)
65
+ // 每工作区快照上限:0 或负值语义 = 不限制(给想全保留的用户出口);
66
+ // 非数值回退默认 500。默认 500 ≈ 重度使用一周量级,太小会静默丢历史
67
+ // 撤回点,太大失去防膨胀意义。
68
+ const rawMax = typeof cfg.maxSnapshotsPerWorkspace === 'number'
69
+ ? cfg.maxSnapshotsPerWorkspace
70
+ : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? '' : cfg.maxSnapshotsPerWorkspace), 10)
71
+ const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500
47
72
 
48
73
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length
49
74
  ? cfg.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
@@ -51,5 +76,21 @@ export function createConfig(raw) {
51
76
 
52
77
  const refillDraft = typeof cfg.refillDraft === 'boolean' ? cfg.refillDraft : true
53
78
 
54
- return { gcSnaps, gcHours, maxFileBytes, baseExcludes, refillDraft }
79
+ // 快照总开关:false 冻结「新建」(session/event 短路,见 index.js),
80
+ // 已有快照的撤回链路不受影响——关闭只停增量,不销毁存量。
81
+ const snapshotEnabled = typeof cfg.snapshotEnabled === 'boolean' ? cfg.snapshotEnabled : true
82
+
83
+ // 撤回后是否归档原会话:关闭时原会话保留在侧栏(fork 新会话仍打开),
84
+ // 供用户对照回退前后上下文;默认开(归档只是隐藏、可恢复)。
85
+ const archiveOriginal = typeof cfg.archiveOriginal === 'boolean' ? cfg.archiveOriginal : true
86
+
87
+ // 按时间保留(S2-3):0 或负值 = 不启用(静默删历史撤回点必须显式
88
+ // opt-in);非数值回退 0。与 maxSnapshotsPerWorkspace(条数维度)并存,
89
+ // 各自独立触发——见 maintenance.enforceRetention。
90
+ const rawDays = typeof cfg.retentionDays === 'number'
91
+ ? cfg.retentionDays
92
+ : parseInt(String(cfg.retentionDays == null ? '' : cfg.retentionDays), 10)
93
+ const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0
94
+
95
+ return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays }
55
96
  }
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
13
13
  */
14
14
 
15
- import { createConfig, Config } from './config.js'
15
+ import { createConfig, Config, DEFAULTS } from './config.js'
16
16
  import { createRuntime } from './store.js'
17
17
  import { createSnapshots } from './snapshots.js'
18
18
  import { createMaintenance } from './maintenance.js'
@@ -21,8 +21,12 @@ import { installSettingsSection } from '@deepseek-ai/dsh-settings'
21
21
  export const name = 'dsh-recall-plugin'
22
22
 
23
23
  // 硬依赖:shell(PowerShell 执行)、sessions(会话/沙箱策略)、
24
- // webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
25
- export const inject = ['shell', 'sessions', 'webServer']
24
+ // webServer(Client 半的 HTTP API 通道)。agents(dsh-base 无条件装配的
25
+ // agent 注册表)为 P0-1 运行中 agent 拦截读运行状态所需——cordis 4 要求
26
+ // 服务在 inject 中声明才可经 ctx.agents 访问,漏声明会抛
27
+ // "cannot get property ... without inject" 导致检查静默 fail-open(冒烟发现)。
28
+ // 其余服务按需 ctx.get。
29
+ export const inject = ['shell', 'sessions', 'webServer', 'agents']
26
30
 
27
31
  // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
28
32
  // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
@@ -77,7 +81,7 @@ export function apply(ctx, config) {
77
81
  // 快照管理列表的结果缓存:磁盘 dump + 冷会话标题即便已批量/并行化,
78
82
  // 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
79
83
  // 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
80
- let listCache = { at: 0, payload: null }
84
+ let listCache = { at: 0, items: null }
81
85
  // 排除配置枚举缓存(30s):exclude-get 首次要遍历工作区、逐文件 shell 读,
82
86
  // 设置页反复打开时不该每次重算;exclude-set 成功写入后立即失效。
83
87
  let excludeCache = { at: 0, payload: null }
@@ -224,6 +228,44 @@ export function apply(ctx, config) {
224
228
  return cwds
225
229
  }
226
230
 
231
+ // 归一化 cwd/root 路径用于跨会话同工作区比对:Windows 大小写不敏感 +
232
+ // 去掉尾部分隔符,避免 D:\Foo 与 d:\foo\ 误判为不同目录。
233
+ function normalizeWorkdir(path) {
234
+ if (!path) return ''
235
+ let p = String(path)
236
+ return (process.platform === 'win32' ? p.toLowerCase() : p).replace(/[\\/]+$/, '')
237
+ }
238
+
239
+ // 回退前重保护检查(P0-1):目标工作区有 agent 正在跑时拒绝预览/撤回。
240
+ // 保守策略——不做自动取消(不替用户做决定),仅拦下操作并提示先停止。
241
+ // API 已在 dsh-agent .d.ts 公开面核验:AgentRegistry.get(id) / list() 均公开,
242
+ // Agent.status ∈ 'idle' | 'running',Agent.session.header.cwd 是活动会话的工作区。
243
+ // 守卫式访问只为防御「未来版本改名 / agent 服务未装配」,失败视为「不忙」
244
+ // (fail-open,不阻断主流程,只损失保护)。
245
+ function agentBusy(sessionId, root) {
246
+ let reg = null
247
+ try { reg = ctx.agents } catch (error) { return false }
248
+ if (!reg) return false
249
+ try {
250
+ if (typeof reg.list === 'function') {
251
+ for (const agent of reg.list()) {
252
+ if (!agent || agent.status !== 'running') continue
253
+ // 发起会话自身的 agent(覆盖最常见场景:本会话 agent 在跑)
254
+ if (sessionId && String(agent.id) === String(sessionId)) return true
255
+ // 跨会话同工作区:另一会话的 agent 在同一个目录跑也会被文件回退波及
256
+ const cwd = agent.session && agent.session.header && agent.session.header.cwd
257
+ if (root && cwd && normalizeWorkdir(cwd) === normalizeWorkdir(root)) return true
258
+ }
259
+ return false
260
+ }
261
+ if (sessionId && typeof reg.get === 'function') {
262
+ const agent = reg.get(sessionId)
263
+ return Boolean(agent && agent.status === 'running')
264
+ }
265
+ } catch (error) { /* fail-open */ }
266
+ return false
267
+ }
268
+
227
269
  // 解析 storesDumpScript 的定界输出:dir → { root, entries }。逐行状态机
228
270
  // (==DIR / ROOT / INDEXBEGIN..INDEXEND),单个 store 的 JSON 损坏只丢它自己。
229
271
  function parseStoresDump(text) {
@@ -411,7 +453,7 @@ export function apply(ctx, config) {
411
453
  rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
412
454
  }
413
455
  }
414
- listCache.payload = null
456
+ listCache.items = null
415
457
  })
416
458
  return deleted
417
459
  }
@@ -488,7 +530,7 @@ export function apply(ctx, config) {
488
530
  }
489
531
  // list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
490
532
  // 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
491
- listCache.payload = null
533
+ listCache.items = null
492
534
  return { deleted, stores: clearedStores, failed }
493
535
  })
494
536
  }
@@ -518,7 +560,7 @@ export function apply(ctx, config) {
518
560
  }
519
561
  // 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
520
562
  // init 是每会话必经的预热通道
521
- return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft } }
563
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft, archiveOriginal: cfg.archiveOriginal } }
522
564
  },
523
565
 
524
566
  'snapshot-info': async (args) => {
@@ -534,26 +576,44 @@ export function apply(ctx, config) {
534
576
  'preview': async (args) => {
535
577
  const id = args && args.messageId ? String(args.messageId) : ''
536
578
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
579
+ // P0-1:目标工作区 agent 运行中直接拒绝预览(避免用户确认时文件被
580
+ // agent 改动,预览清单与实际回退内容脱节)。同会话优先命中(最常见
581
+ // 场景),快照存在时叠加跨会话同工作区检查。
582
+ const snap = state.snapshots.get(id)
583
+ if (agentBusy(sessionId, snap ? snap.root : null)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
537
584
  const result = await enqueue(() => snaps.diffFor(id))
538
585
  if (result === null) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
539
- const snap = state.snapshots.get(id)
586
+ const snap2 = state.snapshots.get(id)
540
587
  const cutSeq = await snaps.resolveCutSeq(sessionId, id)
541
- return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap ? snap.time : null, root: snap ? snap.root : null, cutSeq }
588
+ return { ok: true, changes: result.changes, total: result.total, truncated: result.truncated, time: snap2 ? snap2.time : null, root: snap2 ? snap2.root : null, cutSeq }
542
589
  },
543
590
 
544
591
  'execute': async (args) => {
545
592
  const id = args && args.messageId ? String(args.messageId) : ''
546
593
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
547
594
  const result = await enqueue(async () => {
595
+ const snap = state.snapshots.get(id)
596
+ if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
597
+ const store = state.stores.get(snap.root)
598
+ if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
599
+ // P0-1:队列内第一步——执行前再查一次 agent 状态。检查放在互斥
600
+ // 队列内,检查后紧接执行,中间不可能插进别的操作,窗口为零。
601
+ if (agentBusy(sessionId, snap.root)) return { ok: false, code: 'AGENT_BUSY', message: 'Agent 正在运行中,请先停止后再撤回' }
602
+ // P0-3:preview→execute 失效校验。只由带 previewTotal 的新版
603
+ // Client 触发(老版本/直调 API 不带则跳过,向后兼容)。校验失败
604
+ // 连安全快照都不打——省一次全量 add。同数不同文件的边缘情形由
605
+ // 下方 pre-rollback 安全快照兜底。
606
+ if (args && typeof args.previewTotal === 'number') {
607
+ const fresh = await snaps.diffFor(id)
608
+ if (!fresh || fresh.total !== args.previewTotal) {
609
+ return { ok: false, code: 'STALE', message: '预览后项目文件发生了变化,请重新预览确认' }
610
+ }
611
+ }
548
612
  // 回退前自动打安全快照:回退覆盖工作区且不回写 index(旧的
549
613
  // 「当前状态」从此无任何快照可找回),用消息 ID 打 tag 会与该消息
550
614
  // 的既有快照碰撞,故用独立前缀的时间戳 tag——不进 index.json
551
615
  // (列表不展示),但孤儿重建/手动 git tag 仍能找到它,误回退后
552
616
  // 用户可让插件从该 tag 恢复,堵住唯一的不可逆操作缺口。
553
- const snap = state.snapshots.get(id)
554
- if (!snap) return { ok: false, code: 'NO_SNAPSHOT', message: '该消息没有可用的项目快照' }
555
- const store = state.stores.get(snap.root)
556
- if (!store) return { ok: false, code: 'NO_STORE', message: '快照存储不可用' }
557
617
  const safetyId = 'pre-rollback-' + Date.now()
558
618
  try {
559
619
  await rt.runShell(rt.scripts.snapshotScript(snap.root, store, state.gitExe, safetyId, cfg.baseExcludes), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
@@ -628,8 +688,12 @@ export function apply(ctx, config) {
628
688
  gcSnaps: cfg.gcSnaps,
629
689
  gcHours: cfg.gcHours,
630
690
  maxFileBytes: cfg.maxFileBytes,
691
+ maxSnapshotsPerWorkspace: cfg.maxSnapshotsPerWorkspace,
631
692
  baseExcludes: cfg.baseExcludes.slice(),
632
693
  refillDraft: cfg.refillDraft,
694
+ snapshotEnabled: cfg.snapshotEnabled,
695
+ archiveOriginal: cfg.archiveOriginal,
696
+ retentionDays: cfg.retentionDays,
633
697
  },
634
698
  overridden,
635
699
  envLocks,
@@ -646,7 +710,21 @@ export function apply(ctx, config) {
646
710
  if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
647
711
  if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
648
712
  if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
713
+ if (patch.maxSnapshotsPerWorkspace !== undefined) {
714
+ const n = Number(patch.maxSnapshotsPerWorkspace)
715
+ // 0 或负值 = 不限制(schema 由 number 校验,非法 NaN 在 settings.write 层被拒)
716
+ if (!Number.isFinite(n)) return { ok: false, code: 'BAD_TYPE', message: '快照总量上限必须是数字' }
717
+ clean.maxSnapshotsPerWorkspace = Math.max(0, n)
718
+ }
649
719
  if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
720
+ if (patch.snapshotEnabled !== undefined) clean.snapshotEnabled = Boolean(patch.snapshotEnabled)
721
+ if (patch.archiveOriginal !== undefined) clean.archiveOriginal = Boolean(patch.archiveOriginal)
722
+ if (patch.retentionDays !== undefined) {
723
+ const n = Number(patch.retentionDays)
724
+ // 0/负值 = 不启用(schema 校验 base 由 number 承担,NaN 由 settings.write 拒)
725
+ if (!Number.isFinite(n) || n < 0) return { ok: false, code: 'BAD_TYPE', message: '保留天数必须是 >= 0 的数字(0 表示不启用)' }
726
+ clean.retentionDays = Math.trunc(n)
727
+ }
650
728
  if (patch.baseExcludes !== undefined) {
651
729
  if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
652
730
  clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
@@ -673,8 +751,13 @@ export function apply(ctx, config) {
673
751
  const sessionId = args && args.sessionId ? String(args.sessionId) : null
674
752
  if (op === 'list') {
675
753
  // 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
676
- // 都会重拉列表,缓存让二次打开零 shell
677
- if (listCache.payload && Date.now() - listCache.at < 30000) return listCache.payload
754
+ // 都会重拉列表,缓存让二次打开零 shell。缓存存的是全量排序数组,
755
+ // 响应按请求的 limit 切片——「加载更多」无需重扫磁盘(S1-2)。
756
+ const limitRaw = args && args.limit !== undefined ? Number(args.limit) : 200
757
+ const safeLimit = Math.min(Math.max(Number.isFinite(limitRaw) ? Math.trunc(limitRaw) : 200, 1), 2000)
758
+ if (listCache.items && Date.now() - listCache.at < 30000) {
759
+ return { ok: true, items: listCache.items.slice(0, safeLimit), total: listCache.items.length }
760
+ }
678
761
  const allItems = []
679
762
 
680
763
  // 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
@@ -732,9 +815,8 @@ export function apply(ctx, config) {
732
815
  }
733
816
 
734
817
  allItems.sort((a, b) => (b.time || 0) - (a.time || 0))
735
- const payload = { ok: true, items: allItems.slice(0, 200), total: allItems.length }
736
- listCache = { at: Date.now(), payload }
737
- return payload
818
+ listCache = { at: Date.now(), items: allItems }
819
+ return { ok: true, items: allItems.slice(0, safeLimit), total: allItems.length }
738
820
  }
739
821
  if (op === 'titles') {
740
822
  // 冷会话标题补齐(Client 异步二次请求):readSession 整日志解压 +
@@ -812,12 +894,18 @@ export function apply(ctx, config) {
812
894
  }
813
895
  if (op === 'usage') {
814
896
  let bytes = 0
897
+ // 存储健康统计(S2-4):仅对内存已知 store 计数——与汇总同源,
898
+ // 冷启动预热未完成时不完整,属已知限制(见 plan-settings-ux S2-4)
899
+ let homeStores = 0
900
+ let fallbackStores = 0
815
901
  if (sessionId) {
816
902
  // 旧调用方(带会话上下文):单工作区占用
817
903
  const root = await rt.resolveRoot(sessionId)
818
904
  if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
819
905
  const store = state.stores.get(root)
820
906
  if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
907
+ if (store.home) homeStores++
908
+ else fallbackStores++
821
909
  const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
822
910
  bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
823
911
  } else {
@@ -826,13 +914,15 @@ export function apply(ctx, config) {
826
914
  // 不影响汇总,best-effort。
827
915
  for (const store of state.stores.values()) {
828
916
  if (!store || !store.dir) continue
917
+ if (store.home) homeStores++
918
+ else fallbackStores++
829
919
  try {
830
920
  const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
831
921
  bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
832
922
  } catch (error) { /* 单 store 失败跳过 */ }
833
923
  }
834
924
  }
835
- return { ok: true, bytes }
925
+ return { ok: true, bytes, gitAvailable: state.gitExe !== '', homeStores, fallbackStores }
836
926
  }
837
927
  if (op === 'delete') {
838
928
  // 统一删除入口:scope=workspace 删除整个工作区全部快照;
@@ -889,7 +979,7 @@ export function apply(ctx, config) {
889
979
  state.snapshots.delete(id)
890
980
  await snaps.saveIndex(finalRoot, sessionId)
891
981
  // 列表缓存失效:Client 删除后会立刻 refresh,必须看到最新状态
892
- listCache.payload = null
982
+ listCache.items = null
893
983
  })
894
984
  return { ok: true }
895
985
  }
@@ -916,8 +1006,44 @@ export function apply(ctx, config) {
916
1006
  return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
917
1007
  },
918
1008
 
919
- // 设置页排障:最近错误(Host console.error 的页面可见副本)
920
- 'status': async () => ({ ok: true, errors: state.errors.slice(-20).reverse() })
1009
+ // 设置页「插件配置」卡片恢复默认:整段清空 user 层回组合 base——官方
1010
+ // settings RPC replace 明确是「restoration/reset 路径」(section:{}
1011
+ // 重置为组合默认,见 dsh-host-apiproxy api/settings.d.ts S1-3 核验)。
1012
+ // 比逐字段写默认值干净:重置后字段不再被标 user-overridden,schema 或
1013
+ // cordis patch 行默认值变更时 reset 跟随,不冻结历史值。老版本服务
1014
+ // 没有 replace 时降级 settings.update 写 DEFAULTS(user 层仍出现标记,
1015
+ // 行为等价,缺陷见 plan-settings-ux S1-3)。
1016
+ 'config-reset': async () => {
1017
+ let settings = null
1018
+ try { settings = ctx.get('settings') } catch (error) { settings = null }
1019
+ if (!settings || typeof settings.update !== 'function') {
1020
+ return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
1021
+ }
1022
+ try {
1023
+ if (typeof settings.replace === 'function') {
1024
+ await settings.replace('dsh-recall', {})
1025
+ } else {
1026
+ await settings.update('dsh-recall', Object.assign({}, DEFAULTS, { baseExcludes: DEFAULTS.baseExcludes.slice() }))
1027
+ }
1028
+ } catch (error) {
1029
+ return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '恢复默认失败:' + String(error && error.message ? error.message : error) }
1030
+ }
1031
+ // 重置后热更运行中的 cfg(与 config-set 同链路的 watch 触发,这里做
1032
+ // 双保险:descriptor 已变更,applyResolvedConfig 立即落地)
1033
+ applyResolvedConfig(readSettings())
1034
+ return { ok: true }
1035
+ },
1036
+
1037
+ // 设置页排障:最近错误(Host 侧 console.error 的页面可见副本)。
1038
+ // S3-5:支持 { op: 'clear' } 清空页面可见缓冲——只清 state.errors,
1039
+ // 不影响 console 本身的留痕;清空后设置页「最近错误」为空。
1040
+ 'status': async (args) => {
1041
+ if (args && args.op === 'clear') {
1042
+ state.errors.length = 0
1043
+ return { ok: true, errors: [] }
1044
+ }
1045
+ return { ok: true, errors: state.errors.slice(-20).reverse() }
1046
+ }
921
1047
  }
922
1048
 
923
1049
  ctx.effect(() => webServer.register({
@@ -955,9 +1081,12 @@ export function apply(ctx, config) {
955
1081
  const messageId = data.id
956
1082
  const time = event.time
957
1083
  state.queue = state.queue
958
- .then(() => snaps.captureSnapshot(session.id, messageId, time))
1084
+ // 快照总开关(S2-1):cfg 按调用时读取,设置页热更即时生效。
1085
+ // 关闭时只冻结新建,maybeMaintain 照常跑——已停增的存储仍需被
1086
+ // gc/清理治理。
1087
+ .then(() => (cfg.snapshotEnabled ? snaps.captureSnapshot(session.id, messageId, time) : null))
959
1088
  .then(() => maint.maybeMaintain(session.id))
960
- .then(() => { listCache.payload = null })
1089
+ .then(() => { listCache.items = null })
961
1090
  .catch((error) => rt.recordError('recall snapshot error: ' + String(error)))
962
1091
  })
963
1092