dsh-recall-plugin 1.5.1 → 1.6.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/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 弹一次性提示;
@@ -412,7 +439,9 @@ export function apply(ctx, config) {
412
439
  homeFallback: store ? !store.home : false
413
440
  }
414
441
  }
415
- return { ok: Boolean(root), root: root || null, notice }
442
+ // 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
443
+ // init 是每会话必经的预热通道
444
+ return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft } }
416
445
  },
417
446
 
418
447
  'snapshot-info': async (args) => {
@@ -493,6 +522,68 @@ export function apply(ctx, config) {
493
522
  return { ok: true }
494
523
  },
495
524
 
525
+ // 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段(来自
526
+ // settings.describe 的 user 层,字段在层里出现即用户覆盖)+ env 锁定
527
+ // 字段(环境变量优先级最高,设置改不动)+ 可写性(只读 provider 禁存)。
528
+ 'config-get': async () => {
529
+ const envLocks = {
530
+ gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
531
+ gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
532
+ }
533
+ let overridden = {}
534
+ let writable = false
535
+ try {
536
+ const settings = ctx.get('settings')
537
+ if (settings && typeof settings.describe === 'function') {
538
+ const list = settings.describe()
539
+ const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
540
+ if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
541
+ writable = settings.writable !== false
542
+ }
543
+ } catch (error) { /* describe 不可用按「无覆盖」处理 */ }
544
+ return {
545
+ ok: true,
546
+ values: {
547
+ gcSnaps: cfg.gcSnaps,
548
+ gcHours: cfg.gcHours,
549
+ maxFileBytes: cfg.maxFileBytes,
550
+ baseExcludes: cfg.baseExcludes.slice(),
551
+ refillDraft: cfg.refillDraft,
552
+ },
553
+ overridden,
554
+ envLocks,
555
+ writable,
556
+ }
557
+ },
558
+
559
+ // 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
560
+ // 写进用户层(schema 校验失败会在持久化前 reject,错误信息回显卡片),
561
+ // watch 链路把新值热更新进 cfg,无需重启。
562
+ 'config-set': async (args) => {
563
+ const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
564
+ const clean = {}
565
+ if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
566
+ if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
567
+ if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
568
+ if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
569
+ if (patch.baseExcludes !== undefined) {
570
+ if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
571
+ clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
572
+ }
573
+ if (!Object.keys(clean).length) return { ok: false, code: 'EMPTY_PATCH', message: '没有可写入的配置字段' }
574
+ let settings = null
575
+ try { settings = ctx.get('settings') } catch (error) { settings = null }
576
+ if (!settings || typeof settings.update !== 'function') {
577
+ return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
578
+ }
579
+ try {
580
+ await settings.update('dsh-recall', clean)
581
+ } catch (error) {
582
+ return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '配置写入失败:' + String(error && error.message ? error.message : error) }
583
+ }
584
+ return { ok: true }
585
+ },
586
+
496
587
  // 设置页「快照管理」卡片:列表 / 磁盘占用 / 单条删除 / 手动 gc。
497
588
  // 全部走串行队列——删除 tag 与 gc 与快照争的是同一个 git 仓库。
498
589
  'manage': async (args) => {
@@ -639,12 +730,27 @@ export function apply(ctx, config) {
639
730
  return { ok: true, messageTexts: texts }
640
731
  }
641
732
  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
733
+ let bytes = 0
734
+ if (sessionId) {
735
+ // 旧调用方(带会话上下文):单工作区占用
736
+ const root = await rt.resolveRoot(sessionId)
737
+ if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
738
+ const store = state.stores.get(root)
739
+ if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
740
+ const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
741
+ bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
742
+ } else {
743
+ // 新调用方(settings.plugin.item 卡片无会话上下文):全部已知
744
+ // store 汇总。store 全集取内存缓存(启动预热填齐);单个失败
745
+ // 不影响汇总,best-effort。
746
+ for (const store of state.stores.values()) {
747
+ if (!store || !store.dir) continue
748
+ try {
749
+ const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
750
+ bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
751
+ } catch (error) { /* 单 store 失败跳过 */ }
752
+ }
753
+ }
648
754
  return { ok: true, bytes }
649
755
  }
650
756
  if (op === 'delete') {
@@ -707,7 +813,11 @@ export function apply(ctx, config) {
707
813
  return { ok: true }
708
814
  }
709
815
  if (op === 'gc') {
710
- const done = await enqueue(() => maint.runGc(sessionId, true))
816
+ // 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
817
+ // 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
818
+ const done = sessionId
819
+ ? await enqueue(() => maint.runGc(sessionId, true))
820
+ : await enqueue(() => maint.runGcAll())
711
821
  return { ok: true, gc: Boolean(done) }
712
822
  }
713
823
  return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
@@ -775,8 +885,12 @@ export function apply(ctx, config) {
775
885
  try {
776
886
  const records = await querySvc.listSessions()
777
887
  for (const record of records || []) {
888
+ // listSessions 记录形如 {header, live, persisted},会话 id 在
889
+ // header.id——此前误用顶层 record.id(恒 undefined),预热重建
890
+ // 的孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
891
+ const id = record && record.header && record.header.id ? record.header.id : null
778
892
  const cwd = record && record.header && record.header.cwd
779
- if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, record.id || null)
893
+ if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
780
894
  }
781
895
  } catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
782
896
  }
@@ -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
  }