dsh-recall-plugin 1.5.1 → 1.6.1

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/index.js CHANGED
@@ -12,10 +12,11 @@
12
12
  * 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
13
13
  */
14
14
 
15
- import { createConfig } from './config.js'
15
+ import { createConfig, Config } from './config.js'
16
16
  import { createRuntime } from './store.js'
17
17
  import { createSnapshots } from './snapshots.js'
18
18
  import { createMaintenance } from './maintenance.js'
19
+ import { installSettingsSection } from '@deepseek-ai/dsh-settings'
19
20
 
20
21
  export const name = 'dsh-recall-plugin'
21
22
 
@@ -23,8 +24,13 @@ export const name = 'dsh-recall-plugin'
23
24
  // webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
24
25
  export const inject = ['shell', 'sessions', 'webServer']
25
26
 
26
- // config 由 cordis.patch.yml 的 insert 行 config 键下发,用户在 profile 层
27
- // 按 id: recall 重述该行即可覆盖默认值(见 config.js 头注释)
27
+ // 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
28
+ // 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
29
+ export { Config }
30
+
31
+ // config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
32
+ // 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
33
+ // (见下方 installSettingsSection 接线)
28
34
  export function apply(ctx, config) {
29
35
  const webServer = ctx.webServer
30
36
 
@@ -34,6 +40,27 @@ export function apply(ctx, config) {
34
40
  const maint = createMaintenance(ctx, rt, snaps, cfg)
35
41
  const state = rt.state
36
42
 
43
+ // ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
44
+ // installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
45
+ // 真 Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
46
+ // 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
47
+ // 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg
48
+ // (Object.assign 原地改,各域按调用时读取立即生效)。环境变量仍最高
49
+ // 优先(createConfig 内处理)。settings 服务未组装(非 web 部署)时
50
+ // 整段静默不运行,插件照常以入口配置工作。
51
+ let readSettings = () => config
52
+ function applyResolvedConfig(resolved) {
53
+ Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
54
+ }
55
+ try {
56
+ installSettingsSection(ctx, 'dsh-recall', Config, config, {
57
+ setSource: (fn) => { readSettings = fn },
58
+ onChange: () => applyResolvedConfig(readSettings()),
59
+ })
60
+ } catch (error) {
61
+ rt.recordError('recall settings namespace skipped: ' + String(error))
62
+ }
63
+
37
64
  // 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板
38
65
  // (ctx.shell 由 DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
39
66
  // 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
@@ -389,6 +416,83 @@ export function apply(ctx, config) {
389
416
  return deleted
390
417
  }
391
418
 
419
+ // 删除所有工作区的全部快照。树形管理的「工作区/会话」批量删除以
420
+ // index.json 中的记录为目标,适合保留其他节点;但「全部删除」必须把 git
421
+ // tag 当作真相源:index 可能因旧版/崩溃/手动修复而为空或过期,不能因为
422
+ // 索引里没有条目就漏删真实快照。磁盘枚举到的 store 即使 root.txt 丢失也
423
+ // 直接按目录操作,避免 resolveStore 新建同 root 的另一个空 store。
424
+ async function deleteAllSnapshots() {
425
+ return enqueue(async () => {
426
+ const stores = new Map()
427
+ for (const [root, store] of state.stores.entries()) {
428
+ if (store && store.dir) stores.set(store.dir, { store, root })
429
+ }
430
+ const dump = await dumpStores()
431
+ for (const [dir, info] of dump.entries()) {
432
+ const known = stores.get(dir)
433
+ if (known) {
434
+ if (!known.root && info.root) known.root = info.root
435
+ known.entries = info.entries || []
436
+ } else {
437
+ stores.set(dir, {
438
+ // 全局删除只动该目录下的 git/index;不必、也不能依赖可反解的 root。
439
+ store: rt.storeFromDir(dir, false),
440
+ root: info.root || null,
441
+ entries: info.entries || []
442
+ })
443
+ }
444
+ }
445
+
446
+ if (stores.size === 0) return { deleted: 0, stores: 0, failed: 0 }
447
+
448
+ const gitExe = await rt.resolveGit()
449
+ if (!gitExe) {
450
+ const message = '未检测到 git CLI,无法验证并删除快照 tag'
451
+ rt.recordError('recall delete all failed: ' + message)
452
+ return { deleted: 0, stores: 0, failed: stores.size || 1, message }
453
+ }
454
+
455
+ let deleted = 0
456
+ let clearedStores = 0
457
+ let failed = 0
458
+ for (const { store, root } of stores.values()) {
459
+ try {
460
+ // 先列出实际 tag;不要使用 entries 推导 tag,entries 是可丢失缓存。
461
+ const output = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
462
+ const tags = rt.scripts.stripBom(output).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
463
+ for (let i = 0; i < tags.length; i += 100) {
464
+ await rt.runShell(rt.scripts.purgeTagsScript(store, gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
465
+ }
466
+ // purgeTagsScript 为幂等 best-effort,故必须回读校验,避免脚本吞掉
467
+ // 个别失败后仍错误地把 index.json 清空。
468
+ const remainedOutput = await rt.runShell(rt.scripts.listTagsScript(store, gitExe), { timeoutMs: 120000, stdoutMaxBytes: 4194304 })
469
+ const remained = rt.scripts.stripBom(remainedOutput).split(/\r?\n/).map((tag) => tag.trim()).filter((tag) => tag.indexOf('snap-') === 0)
470
+ if (remained.length) throw new Error('仍有 ' + remained.length + ' 个快照 tag 未删除')
471
+
472
+ // tag 清理被确认后才清空索引。直接写已枚举的 store,兼容 root.txt
473
+ // 缺失/错位的旧仓库;不能调用 saveIndex(root),后者会重新按 root 寻址。
474
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', '[]')
475
+ for (const tag of tags) state.snapshots.delete(tag.slice('snap-'.length))
476
+ if (root) {
477
+ for (const [id, snap] of state.snapshots.entries()) {
478
+ if (snap && snap.root === root) state.snapshots.delete(id)
479
+ }
480
+ state.indexLoaded.add(root)
481
+ }
482
+ deleted += tags.length
483
+ clearedStores += 1
484
+ } catch (error) {
485
+ failed += 1
486
+ rt.recordError('recall delete all failed for ' + store.dir + ': ' + String(error))
487
+ }
488
+ }
489
+ // list 既合并内存也 dump 磁盘;无论完全/部分完成都必须失效,才能让
490
+ // 成功删除的 store 立即从树上消失,而失败 store 仍保留供用户重试。
491
+ listCache.payload = null
492
+ return { deleted, stores: clearedStores, failed }
493
+ })
494
+ }
495
+
392
496
  const endpoints = {
393
497
  'init': async (args) => {
394
498
  if (!supported) {
@@ -412,7 +516,9 @@ export function apply(ctx, config) {
412
516
  homeFallback: store ? !store.home : false
413
517
  }
414
518
  }
415
- return { ok: Boolean(root), root: root || null, notice }
519
+ // 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
520
+ // init 是每会话必经的预热通道
521
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft } }
416
522
  },
417
523
 
418
524
  'snapshot-info': async (args) => {
@@ -493,6 +599,68 @@ export function apply(ctx, config) {
493
599
  return { ok: true }
494
600
  },
495
601
 
602
+ // 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段(来自
603
+ // settings.describe 的 user 层,字段在层里出现即用户覆盖)+ env 锁定
604
+ // 字段(环境变量优先级最高,设置改不动)+ 可写性(只读 provider 禁存)。
605
+ 'config-get': async () => {
606
+ const envLocks = {
607
+ gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
608
+ gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
609
+ }
610
+ let overridden = {}
611
+ let writable = false
612
+ try {
613
+ const settings = ctx.get('settings')
614
+ if (settings && typeof settings.describe === 'function') {
615
+ const list = settings.describe()
616
+ const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
617
+ if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
618
+ writable = settings.writable !== false
619
+ }
620
+ } catch (error) { /* describe 不可用按「无覆盖」处理 */ }
621
+ return {
622
+ ok: true,
623
+ values: {
624
+ gcSnaps: cfg.gcSnaps,
625
+ gcHours: cfg.gcHours,
626
+ maxFileBytes: cfg.maxFileBytes,
627
+ baseExcludes: cfg.baseExcludes.slice(),
628
+ refillDraft: cfg.refillDraft,
629
+ },
630
+ overridden,
631
+ envLocks,
632
+ writable,
633
+ }
634
+ },
635
+
636
+ // 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
637
+ // 写进用户层(schema 校验失败会在持久化前 reject,错误信息回显卡片),
638
+ // watch 链路把新值热更新进 cfg,无需重启。
639
+ 'config-set': async (args) => {
640
+ const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
641
+ const clean = {}
642
+ if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
643
+ if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
644
+ if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
645
+ if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
646
+ if (patch.baseExcludes !== undefined) {
647
+ if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
648
+ clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
649
+ }
650
+ if (!Object.keys(clean).length) return { ok: false, code: 'EMPTY_PATCH', message: '没有可写入的配置字段' }
651
+ let settings = null
652
+ try { settings = ctx.get('settings') } catch (error) { settings = null }
653
+ if (!settings || typeof settings.update !== 'function') {
654
+ return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
655
+ }
656
+ try {
657
+ await settings.update('dsh-recall', clean)
658
+ } catch (error) {
659
+ return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '配置写入失败:' + String(error && error.message ? error.message : error) }
660
+ }
661
+ return { ok: true }
662
+ },
663
+
496
664
  // 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
497
665
  // 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
498
666
  'manage': async (args) => {
@@ -639,12 +807,27 @@ export function apply(ctx, config) {
639
807
  return { ok: true, messageTexts: texts }
640
808
  }
641
809
  if (op === 'usage') {
642
- const root = await rt.resolveRoot(sessionId)
643
- if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
644
- const store = state.stores.get(root)
645
- if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
646
- const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
647
- const bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
810
+ let bytes = 0
811
+ if (sessionId) {
812
+ // 旧调用方(带会话上下文):单工作区占用
813
+ const root = await rt.resolveRoot(sessionId)
814
+ if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
815
+ const store = state.stores.get(root)
816
+ if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
817
+ const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
818
+ bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
819
+ } else {
820
+ // 新调用方(settings.plugin.item 卡片无会话上下文):全部已知
821
+ // store 汇总。store 全集取内存缓存(启动预热填齐);单个失败
822
+ // 不影响汇总,best-effort。
823
+ for (const store of state.stores.values()) {
824
+ if (!store || !store.dir) continue
825
+ try {
826
+ const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
827
+ bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
828
+ } catch (error) { /* 单 store 失败跳过 */ }
829
+ }
830
+ }
648
831
  return { ok: true, bytes }
649
832
  }
650
833
  if (op === 'delete') {
@@ -706,8 +889,24 @@ export function apply(ctx, config) {
706
889
  })
707
890
  return { ok: true }
708
891
  }
892
+ if (op === 'deleteAll') {
893
+ const result = await deleteAllSnapshots()
894
+ if (result.failed > 0) {
895
+ return {
896
+ ok: false,
897
+ code: 'PARTIAL_DELETE',
898
+ deleted: result.deleted,
899
+ message: result.message || ('已删除 ' + result.deleted + ' 条快照,但有 ' + result.failed + ' 个存储未完成;请查看最近错误后重试')
900
+ }
901
+ }
902
+ return { ok: true, deleted: result.deleted, stores: result.stores }
903
+ }
709
904
  if (op === 'gc') {
710
- const done = await enqueue(() => maint.runGc(sessionId, true))
905
+ // 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
906
+ // 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
907
+ const done = sessionId
908
+ ? await enqueue(() => maint.runGc(sessionId, true))
909
+ : await enqueue(() => maint.runGcAll())
711
910
  return { ok: true, gc: Boolean(done) }
712
911
  }
713
912
  return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
@@ -775,8 +974,12 @@ export function apply(ctx, config) {
775
974
  try {
776
975
  const records = await querySvc.listSessions()
777
976
  for (const record of records || []) {
977
+ // listSessions 记录形如 {header, live, persisted},会话 id 在
978
+ // header.id——此前误用顶层 record.id(恒 undefined),预热重建
979
+ // 的孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
980
+ const id = record && record.header && record.header.id ? record.header.id : null
778
981
  const cwd = record && record.header && record.header.cwd
779
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, record.id || null)
982
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
780
983
  }
781
984
  } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
782
985
  }
@@ -11,13 +11,12 @@
11
11
  * 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
12
12
  */
13
13
 
14
- // gc 节流阈值来自 config 域(用户可在 profile 的 cordis.patch.yml 覆盖,
15
- // 环境变量 DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
14
+ // gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
15
+ // 因此这里按调用时取值而不是工厂创建时快照;环境变量
16
+ // DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
16
17
  // 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
17
18
  // 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
18
19
  export function createMaintenance(ctx, rt, snaps, config) {
19
- const GC_SNAPS = config.gcSnaps
20
- const GC_MS = config.gcHours * 3600000
21
20
  const sessions = ctx.sessions
22
21
  const state = rt.state
23
22
  // 平台选择的脚本模板(gc/purge 两套模板同名导出)
@@ -97,7 +96,7 @@ export function createMaintenance(ctx, rt, snaps, config) {
97
96
  const last = state.gcLastAt.get(store.git) || 0
98
97
  const count = (state.gcCount.get(store.git) || 0) + 1
99
98
  state.gcCount.set(store.git, count)
100
- if (!force && count < GC_SNAPS && now - last < GC_MS) return false
99
+ if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
101
100
  state.gcCount.set(store.git, 0)
102
101
  try {
103
102
  await sweepDeletedSessions()
@@ -109,12 +108,38 @@ export function createMaintenance(ctx, rt, snaps, config) {
109
108
  return true
110
109
  }
111
110
 
111
+ // 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
112
+ // store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
113
+ // best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
114
+ // 串行队列,与快照天然互斥,无 git 锁竞态。
115
+ async function runGcAll() {
116
+ const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
117
+ if (!stores.length || !state.gitExe) return false
118
+ try {
119
+ await sweepDeletedSessions()
120
+ } catch (error) {
121
+ rt.recordError('recall sweep failed: ' + String(error))
122
+ }
123
+ let done = 0
124
+ for (const store of stores) {
125
+ try {
126
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
127
+ done++
128
+ } catch (error) {
129
+ rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
130
+ }
131
+ state.gcLastAt.set(store.git, Date.now())
132
+ state.gcCount.set(store.git, 0)
133
+ }
134
+ return true
135
+ }
136
+
112
137
  // 每条消息快照后串行调用(见 index.js 事件接线)
113
138
  async function maybeMaintain(sessionId) {
114
139
  await runGc(sessionId, false)
115
140
  }
116
141
 
117
- // 模块收敛:只导出唯一被装配层消费的入口;sweepDeletedSessions /
118
- // purgeSession 是 runGc 的内部步骤,不对外暴露面
119
- return { maybeMaintain, runGc }
142
+ // 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
143
+ // purgeSession 是 gc 的内部步骤,不对外暴露面
144
+ return { maybeMaintain, runGc, runGcAll }
120
145
  }
@@ -71,7 +71,9 @@ function oversizeBlock(maxBytes) {
71
71
  // base 基础排除表按调用注入(config.baseExcludes 可调),不硬编码。
72
72
  // 依赖外层已定义的 $git/$g。
73
73
  function excludeSyncBlock(excludeFile, base) {
74
- const baseList = Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/']
74
+ // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
75
+ // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
76
+ const baseList = Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
75
77
  const baseLines = baseList.join('\n') + '\n'
76
78
  return [
77
79
  'ex_file=' + psq(excludeFile),
@@ -258,6 +260,9 @@ export function listTagsScript(store, gitExe) {
258
260
  'set -e',
259
261
  'git=' + psq(gitExe),
260
262
  'g=' + psq(store.git),
263
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
264
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
265
+ '[ -d "$g" ] || exit 0',
261
266
  '"$git" --git-dir="$g" tag -l \'snap-*\''
262
267
  ].join('\n')
263
268
  }
@@ -73,7 +73,9 @@ function oversizeBlock(maxBytes) {
73
73
  // - 依赖外层已定义的 $git/$g;被 ensureGit/snapshot/diff/rollback 复用,
74
74
  // 因此 exclude.txt 的改动在下一次快照/diff/回退时即时生效,无需重启。
75
75
  function excludeSyncBlock(excludeFile, base) {
76
- const baseList = (Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/']).map(psq).join(',')
76
+ // 兜底含两种存储目录名:降级为 .dsh-recall-snapshots/,home 存储为
77
+ // dsh-recall-snapshots/(root=HOME 时落入工作区,漏排除会自吞,issue #6)
78
+ const baseList = (Array.isArray(base) && base.length ? base : ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).map(psq).join(',')
77
79
  return [
78
80
  "$exFile = " + psq(excludeFile),
79
81
  '$userPats = @()',
@@ -288,6 +290,9 @@ export function listTagsScript(store, gitExe) {
288
290
  "$ErrorActionPreference = 'Stop'",
289
291
  '$git = ' + psq(gitExe),
290
292
  '$g = ' + psq(store.git),
293
+ // 仅创建过 store 目录、尚未产生过快照时没有 git/.git;把它视为
294
+ // 空快照仓库而非错误,全部删除仍可顺便清空其陈旧 index.json。
295
+ 'if (-not (Test-Path -LiteralPath $g -PathType Container)) { exit 0 }',
291
296
  '& $git --git-dir=$g tag -l "snap-*"'
292
297
  ].join('\n')
293
298
  }