dsh-recall-plugin 1.6.1 → 1.6.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
4
4
 
5
+ ## [1.6.2] - 2026-08-25
6
+
7
+ ### 修复
8
+
9
+ - 用户消息图片显示空白且刷新无效([#8](https://github.com/limbo947/dsh-recall-plugin/issues/8)):会话回放时最早的图片消息先于会话 binding 就绪渲染,`loadImage` 以 unknown session 拒绝后被静默 `catch` 吞掉,图片永久空白。图片加载改为 400ms/1.5s/4s 三次退避自动重试(暂态失败自愈),耗尽后显示「图片加载失败,点击重试」失败态(对齐官方 MessageImage 语义);顺带修复 attachment 切换时短暂残留上一张图的问题。
10
+ - 快照连续失败无限累积磁盘残骸([#7](https://github.com/limbo947/dsh-recall-plugin/issues/7) 评论实测一个下午 127GB dangling 对象):失败重试的每次 `git add` 都会写入无 tag 可达的残骸对象。两道防线——
11
+ - **失败清理**:每次快照失败后执行 `git prune`(以 refs + 暂存 index 为根做可达性删除),只清当次残骸、不碰任何 tag 快照,已暂存对象保留以维持下次增量。
12
+ - **熔断退避**:连续 3 次失败后按 5min 起步指数翻倍、60min 封顶的退避跳过快照,冷却期满自动重试,成功一次即全部复位;进入熔断只在跳变沿记一条最近错误。
13
+
5
14
  ## [1.6.1] - 2026-08-23
6
15
 
7
16
  ### 新增
package/README.md CHANGED
@@ -12,8 +12,11 @@
12
12
 
13
13
  ---
14
14
  **在任意一条你发过的消息下方**,**点「↶ 撤回」**,**工作区文件和对话历史一起回到那条消息发出之前的状态**。
15
+ ---
15
16
 
17
+ [更新日志](CHANGELOG.md)
16
18
  ## 界面预览
19
+
17
20
  - 撤回按钮位置
18
21
 
19
22
  ![悬停出现撤回按钮](docs/screenshots/recall-button.png)
@@ -23,7 +26,7 @@
23
26
  | --- | --- |
24
27
  | ![确认面板 · 变更文件清单](docs/screenshots/confirm-panel-1.png) | ![确认面板](docs/screenshots/confirm-panel-2.png) |
25
28
 
26
- - 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
29
+ - 撤回后自动把消息文本回填到输入框,方便修改后重发(可在设置卡片关闭)
27
30
  - 设置页 · 插件配置卡片(阈值 / 排除表 / 快照管理,保存即热生效)
28
31
 
29
32
  ![设置页](docs/screenshots/settings-exclude-2.png)
@@ -126,6 +129,4 @@ Copy-Item -Recurse -Force $pkg "$profile\node_modules\dsh-recall-plugin"
126
129
 
127
130
  MIT
128
131
 
129
- ---
130
132
 
131
- [更新日志](CHANGELOG.md)
package/lib/client.js CHANGED
@@ -39,6 +39,8 @@ window.__ModuleLoader__.load({
39
39
  '.dsh-recall-stack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}',
40
40
  '.dsh-recall-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word}',
41
41
  '.dsh-recall-img{max-width:100%;max-height:320px;border-radius:12px;object-fit:contain}',
42
+ '.dsh-recall-img-failed{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:8px 14px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0}',
43
+ '.dsh-recall-img-failed:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
42
44
  '.dsh-recall-json{margin:0;max-width:100%;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-markdown-code-block)}',
43
45
  '.dsh-recall-actions{align-items:center;gap:10px;height:28px;display:flex}',
44
46
  '.dsh-recall-time{color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px;font-size:14px;line-height:24px}',
@@ -247,23 +249,50 @@ window.__ModuleLoader__.load({
247
249
  )
248
250
  }
249
251
 
252
+ // 图片加载失败的暂态判定(issue #8):会话回放时最早的图片消息先于
253
+ // 会话 binding 就绪渲染,loadImage 以 unknown session 拒绝——这类
254
+ // 失败靠短退避自动重试即可自愈;此前 `.catch(() => {})` 把失败静默
255
+ // 吞掉且 src 恒为 null,图片永久空白且刷新无法恢复。自动重试耗尽
256
+ // 后置 failed,由 ImageBox 提供「点击重试」(对齐官方 MessageImage
257
+ // 的失败态语义),attempt 仅被手动重试递增以复用整条加载链。
258
+ const IMAGE_RETRY_DELAYS = [400, 1500, 4000]
259
+
250
260
  function useImageSrc(attachment, loadImage) {
251
261
  const [src, setSrc] = React.useState(null)
262
+ const [failed, setFailed] = React.useState(false)
263
+ const [attempt, setAttempt] = React.useState(0)
252
264
  React.useEffect(() => {
253
265
  let alive = true
266
+ let timer = null
254
267
  if (!attachment || typeof loadImage !== 'function') return undefined
255
- Promise.resolve(loadImage(attachment)).then((url) => {
256
- if (alive && url) setSrc(String(url))
257
- }).catch(() => {})
258
- return () => { alive = false }
259
- }, [attachment, loadImage])
260
- return src
268
+ // 复位旧状态:attachment 变化或手动重试时清掉上一轮的 src/failed,
269
+ // 避免加载期间短暂显示上一张图或残留的失败态
270
+ setSrc(null)
271
+ setFailed(false)
272
+ const tryLoad = (retried) => {
273
+ Promise.resolve(loadImage(attachment)).then((url) => {
274
+ if (!alive) return
275
+ if (url) setSrc(String(url))
276
+ else if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
277
+ else setFailed(true)
278
+ }).catch(() => {
279
+ if (!alive) return
280
+ if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
281
+ else setFailed(true)
282
+ })
283
+ }
284
+ tryLoad(0)
285
+ return () => { alive = false; if (timer) clearTimeout(timer) }
286
+ }, [attachment, loadImage, attempt])
287
+ return { src, failed, retry: () => setAttempt(attempt + 1) }
261
288
  }
262
289
 
263
290
  function ImageBox(props) {
264
- const src = useImageSrc(props.attachment, props.loadImage)
265
- if (!src) return null
266
- return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
291
+ const { src, failed, retry } = useImageSrc(props.attachment, props.loadImage)
292
+ if (src) return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
293
+ // 加载中保持原零占位语义(不撑气泡);只在确定失败后渲染可重试入口
294
+ if (failed) return React.createElement('button', { type: 'button', className: 'dsh-recall-img-failed', onClick: retry }, '图片加载失败,点击重试')
295
+ return null
267
296
  }
268
297
 
269
298
  // kind 语义单表承载(文案/徽章类名/汇总顺序):新增 kind 时只改
@@ -1,145 +1,145 @@
1
- /**
2
- * dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
3
- *
4
- * 职责:磁盘占用治理,两件事——
5
- * 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
6
- * 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
7
- * 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
8
- * 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
9
- *
10
- * 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
11
- * 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
12
- */
13
-
14
- // gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
15
- // 因此这里按调用时取值而不是工厂创建时快照;环境变量
16
- // DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
17
- // 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
18
- // 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
19
- export function createMaintenance(ctx, rt, snaps, config) {
20
- const sessions = ctx.sessions
21
- const state = rt.state
22
- // 平台选择的脚本模板(gc/purge 两套模板同名导出)
23
- const S = rt.scripts
24
-
25
- // 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
26
- // tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
27
- // best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
28
- async function purgeSession(sessionId) {
29
- const byRoot = new Map()
30
- for (const [id, s] of state.snapshots.entries()) {
31
- if (!s || s.sessionId !== sessionId) continue
32
- if (!byRoot.has(s.root)) byRoot.set(s.root, [])
33
- byRoot.get(s.root).push(id)
34
- }
35
- let purged = 0
36
- for (const [root, ids] of byRoot) {
37
- let store = state.stores.get(root)
38
- if (!store) {
39
- // 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
40
- // 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
41
- try { store = await rt.resolveStore(root) } catch (error) { store = null }
42
- }
43
- if (!store || !state.gitExe) continue
44
- try {
45
- for (let i = 0; i < ids.length; i += 100) {
46
- await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
47
- }
48
- for (const id of ids) state.snapshots.delete(id)
49
- await snaps.saveIndex(root, sessionId)
50
- purged += ids.length
51
- } catch (error) {
52
- rt.recordError('recall purge session failed: ' + String(error))
53
- }
54
- }
55
- if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
56
- return purged
57
- }
58
-
59
- // 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
60
- // 才认定「已删除」。两个保守闸门:
61
- // - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
62
- // 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
63
- // - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
64
- // 不会被误清——只有日志真正消失才触发。
65
- async function sweepDeletedSessions() {
66
- const ids = new Set()
67
- for (const s of state.snapshots.values()) {
68
- if (s && s.sessionId) ids.add(s.sessionId)
69
- }
70
- if (!ids.size) return
71
- const query = ctx.get('sessionQuery')
72
- if (!query || typeof query.readSession !== 'function') return
73
- for (const id of ids) {
74
- if (sessions.get(id)) continue
75
- let alive = false
76
- try {
77
- const log = await query.readSession(id)
78
- alive = Boolean(log)
79
- } catch (error) {
80
- alive = false
81
- }
82
- if (!alive) await purgeSession(id)
83
- }
84
- }
85
-
86
- // 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
87
- // 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
88
- // 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
89
- // 会让后续每条消息都重试一次重量级 gc,把队列堵住。
90
- async function runGc(sessionId, force) {
91
- const root = await rt.resolveRoot(sessionId)
92
- if (!root) return false
93
- const store = state.stores.get(root)
94
- if (!store || !state.gitExe) return false
95
- const now = Date.now()
96
- const last = state.gcLastAt.get(store.git) || 0
97
- const count = (state.gcCount.get(store.git) || 0) + 1
98
- state.gcCount.set(store.git, count)
99
- if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
100
- state.gcCount.set(store.git, 0)
101
- try {
102
- await sweepDeletedSessions()
103
- await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
104
- } catch (error) {
105
- rt.recordError('recall maintenance failed: ' + String(error))
106
- }
107
- state.gcLastAt.set(store.git, Date.now())
108
- return true
109
- }
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
-
137
- // 每条消息快照后串行调用(见 index.js 事件接线)
138
- async function maybeMaintain(sessionId) {
139
- await runGc(sessionId, false)
140
- }
141
-
142
- // 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
143
- // purgeSession 是 gc 的内部步骤,不对外暴露面
144
- return { maybeMaintain, runGc, runGcAll }
145
- }
1
+ /**
2
+ * dsh-recall-plugin — 快照维护(ctx 绑定的工厂,无模块级副作用)
3
+ *
4
+ * 职责:磁盘占用治理,两件事——
5
+ * 1. 定期 git gc:全量保留策略下把 loose 对象压 pack + 跨版本 delta,
6
+ * 无损(所有 tag 可达对象一个不丢),通常省一半以上空间;
7
+ * 2. 会话删除联动清理:会话日志已从磁盘消失时,删除该会话全部快照 tag
8
+ * 并重写索引,空间由紧随的同一次 gc --prune=now 真正释放。
9
+ *
10
+ * 触发点在每条用户消息快照之后的同一条串行队列里(见 index.js 的事件
11
+ * 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
12
+ */
13
+
14
+ // gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
15
+ // 因此这里按调用时取值而不是工厂创建时快照;环境变量
16
+ // DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
17
+ // 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
18
+ // 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
19
+ export function createMaintenance(ctx, rt, snaps, config) {
20
+ const sessions = ctx.sessions
21
+ const state = rt.state
22
+ // 平台选择的脚本模板(gc/purge 两套模板同名导出)
23
+ const S = rt.scripts
24
+
25
+ // 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
26
+ // tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
27
+ // best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
28
+ async function purgeSession(sessionId) {
29
+ const byRoot = new Map()
30
+ for (const [id, s] of state.snapshots.entries()) {
31
+ if (!s || s.sessionId !== sessionId) continue
32
+ if (!byRoot.has(s.root)) byRoot.set(s.root, [])
33
+ byRoot.get(s.root).push(id)
34
+ }
35
+ let purged = 0
36
+ for (const [root, ids] of byRoot) {
37
+ let store = state.stores.get(root)
38
+ if (!store) {
39
+ // 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
40
+ // 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
41
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
42
+ }
43
+ if (!store || !state.gitExe) continue
44
+ try {
45
+ for (let i = 0; i < ids.length; i += 100) {
46
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
47
+ }
48
+ for (const id of ids) state.snapshots.delete(id)
49
+ await snaps.saveIndex(root, sessionId)
50
+ purged += ids.length
51
+ } catch (error) {
52
+ rt.recordError('recall purge session failed: ' + String(error))
53
+ }
54
+ }
55
+ if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
56
+ return purged
57
+ }
58
+
59
+ // 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
60
+ // 才认定「已删除」。两个保守闸门:
61
+ // - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
62
+ // 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
63
+ // - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
64
+ // 不会被误清——只有日志真正消失才触发。
65
+ async function sweepDeletedSessions() {
66
+ const ids = new Set()
67
+ for (const s of state.snapshots.values()) {
68
+ if (s && s.sessionId) ids.add(s.sessionId)
69
+ }
70
+ if (!ids.size) return
71
+ const query = ctx.get('sessionQuery')
72
+ if (!query || typeof query.readSession !== 'function') return
73
+ for (const id of ids) {
74
+ if (sessions.get(id)) continue
75
+ let alive = false
76
+ try {
77
+ const log = await query.readSession(id)
78
+ alive = Boolean(log)
79
+ } catch (error) {
80
+ alive = false
81
+ }
82
+ if (!alive) await purgeSession(id)
83
+ }
84
+ }
85
+
86
+ // 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
87
+ // 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
88
+ // 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
89
+ // 会让后续每条消息都重试一次重量级 gc,把队列堵住。
90
+ async function runGc(sessionId, force) {
91
+ const root = await rt.resolveRoot(sessionId)
92
+ if (!root) return false
93
+ const store = state.stores.get(root)
94
+ if (!store || !state.gitExe) return false
95
+ const now = Date.now()
96
+ const last = state.gcLastAt.get(store.git) || 0
97
+ const count = (state.gcCount.get(store.git) || 0) + 1
98
+ state.gcCount.set(store.git, count)
99
+ if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
100
+ state.gcCount.set(store.git, 0)
101
+ try {
102
+ await sweepDeletedSessions()
103
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
104
+ } catch (error) {
105
+ rt.recordError('recall maintenance failed: ' + String(error))
106
+ }
107
+ state.gcLastAt.set(store.git, Date.now())
108
+ return true
109
+ }
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
+
137
+ // 每条消息快照后串行调用(见 index.js 事件接线)
138
+ async function maybeMaintain(sessionId) {
139
+ await runGc(sessionId, false)
140
+ }
141
+
142
+ // 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
143
+ // purgeSession 是 gc 的内部步骤,不对外暴露面
144
+ return { maybeMaintain, runGc, runGcAll }
145
+ }
@@ -279,6 +279,18 @@ export function gcScript(store, gitExe) {
279
279
  ].join('\n')
280
280
  }
281
281
 
282
+ // 快照失败后的残骸清理(语义同 pwsh 版,动机详见其注释):prune 以
283
+ // refs + 暂存 index 为根删无引用对象,只清失败 add 的残骸、不碰 tag 快照
284
+ export function pruneScript(store, gitExe) {
285
+ return [
286
+ 'set -e',
287
+ 'git=' + psq(gitExe),
288
+ 'g=' + psq(store.git),
289
+ '"$git" --git-dir="$g" prune',
290
+ 'echo PRUNE_OK'
291
+ ].join('\n')
292
+ }
293
+
282
294
  // 删除指定快照 tag(会话已删联动清理用):tag -d 对不存在 tag 非零退出,
283
295
  // || true 吞掉——best-effort,残留 tag 由下次清理幂等收尾(同 pwsh 版)
284
296
  export function purgeTagsScript(store, gitExe, tags) {
@@ -314,6 +314,22 @@ export function gcScript(store, gitExe) {
314
314
  ].join('\n')
315
315
  }
316
316
 
317
+ // 快照失败后的残骸清理(issue #7 实测:失败重试一个下午可积累 127GB
318
+ // dangling 对象)。失败的 add 已把部分 blob 写进对象库,但 write-tree/
319
+ // commit/tag 未发生——这些对象无引用可达;git prune 以 refs + 暂存 index
320
+ // 为根做可达性删除,正好只清掉这批无主对象,不碰任何 tag 快照。不做 gc:
321
+ // gc 是全量 repack 重活,失败重试场景下对象库往往已被残骸撑大,代价过高。
322
+ // 调用点在 captureSnapshot 的 catch 里,与快照同走一条串行队列,无锁竞态。
323
+ export function pruneScript(store, gitExe) {
324
+ return [
325
+ "$ErrorActionPreference = 'Stop'",
326
+ '$git = ' + psq(gitExe),
327
+ '$g = ' + psq(store.git),
328
+ '& $git --git-dir=$g prune',
329
+ "Write-Output 'PRUNE_OK'"
330
+ ].join('\n')
331
+ }
332
+
317
333
  // 删除指定快照 tag(会话已删联动清理用)。best-effort:个别 tag 已不存在时
318
334
  // git 非零退出,但其余 tag 已被删除——所以显式 exit 0 吞掉退出码,
319
335
  // 残留的由下一次清理幂等地收尾;JS 侧无论脚本结果都会同步索引。
package/lib/snapshots.js CHANGED
@@ -1,216 +1,258 @@
1
- /**
2
- * dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
3
- *
4
- * 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
5
- * diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
6
- * (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
7
- * 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
8
- */
9
-
10
- export function createSnapshots(ctx, rt, config) {
11
- const sessions = ctx.sessions
12
- const state = rt.state
13
- // 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
14
- // 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
15
- const S = rt.scripts
16
- // 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
17
- // 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
18
- // 下一次快照/diff/回退立即按新排除表执行
19
- const BASE = () => config.baseExcludes
20
-
21
- // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
22
- // 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
23
- // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
24
- async function saveIndex(root, sessionId) {
25
- const store = state.stores.get(root)
26
- if (!store) return
27
- // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
28
- // root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
29
- // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
30
- // 旧版本插件读新索引也只取已知字段,双向兼容。
31
- const entries = Array.from(state.snapshots.entries())
32
- .filter(([, s]) => s.root === root)
33
- .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
34
- try {
35
- await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
36
- } catch (error) {
37
- rt.recordError('recall saveIndex failed: ' + String(error))
38
- }
39
- }
40
-
41
- async function loadIndex(root, sessionId) {
42
- if (state.indexLoaded.has(root)) return
43
- const store = state.stores.get(root)
44
- if (!store) return
45
- try {
46
- const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
47
- if (!raw) { state.indexLoaded.add(root); return }
48
- const entries = JSON.parse(raw)
49
- if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
50
- for (const entry of entries) {
51
- if (!entry || typeof entry.id !== 'string') continue
52
- state.snapshots.set(entry.id, {
53
- root,
54
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
55
- sessionId: entry.sessionId || sessionId
56
- })
57
- }
58
- // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
59
- // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
60
- // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
61
- state.indexLoaded.add(root)
62
- } catch (error) {
63
- /* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
64
- }
65
- }
66
-
67
- // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
68
- // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
69
- // 文件都输出空串,这里不用区分「没配过」和「配了空」。
70
- async function readExclude(store) {
71
- return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
72
- }
73
-
74
- // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
75
- // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
76
- // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
77
- async function writeExclude(store, text) {
78
- const body = String(text == null ? '' : text)
79
- const sep = rt.isWin ? '\\' : '/'
80
- const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
81
- await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
82
- await rt.writeTextViaShell(store.excludeFile, body)
83
- }
84
-
85
- // 索引丢失时从仓库 tag 重建:tag snap-<messageId> 本身就是快照主键
86
- async function rebuildOrphans(root, sessionId) {
87
- const store = state.stores.get(root)
88
- const gitExe = await rt.resolveGit()
89
- if (!store || !gitExe) return
90
- try {
91
- const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
92
- if (!listing) return
93
- for (const name of listing.split(/\r?\n/)) {
94
- const id = name.trim().replace(/^snap-/, '')
95
- if (!id || state.snapshots.has(id)) continue
96
- state.snapshots.set(id, { root, time: 0, sessionId })
97
- }
98
- await saveIndex(root, sessionId)
99
- } catch (error) {
100
- rt.recordError('recall rebuildOrphans failed: ' + String(error))
101
- }
102
- }
103
-
104
- async function captureSnapshot(sessionId, messageId, time) {
105
- const root = await rt.resolveRoot(sessionId)
106
- if (!root) return
107
- let store = await rt.resolveStore(root)
108
- store = await rt.tryUpgradeToHome(root)
109
- const ok = await rt.ensureGit(root, store)
110
- if (!ok) return
111
- await loadIndex(root, sessionId)
112
- try {
113
- await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
114
- state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
115
- await saveIndex(root, sessionId)
116
- } catch (error) {
117
- rt.recordError('recall snapshot failed: ' + String(error))
118
- }
119
- }
120
-
121
- // POSIX diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
122
- // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
123
- function parseChanges(text) {
124
- if (rt.isWin) {
125
- const parsed = JSON.parse(text)
126
- if (Array.isArray(parsed)) return parsed
127
- if (parsed && typeof parsed === 'object') return [parsed]
128
- return []
129
- }
130
- const out = []
131
- for (const line of text.split(/\r?\n/)) {
132
- if (!line) continue
133
- const tab = line.indexOf('\t')
134
- if (tab < 0) continue
135
- out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
136
- }
137
- return out
138
- }
139
-
140
- // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM JSON
141
- // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
142
- // 汇总展示;total 保留完整计数让面板文案仍准确。
143
- const MAX_CHANGES = 500
144
-
145
- async function diffFor(messageId) {
146
- const snap = state.snapshots.get(String(messageId))
147
- if (!snap) return null
148
- const store = state.stores.get(snap.root)
149
- if (!store) return null
150
- // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
151
- // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
152
- const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
153
- const trimmed = text.trim()
154
- if (!trimmed) return { changes: [], total: 0, truncated: false }
155
- const all = parseChanges(trimmed)
156
- return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
157
- }
158
-
159
- async function rollbackFor(messageId) {
160
- const snap = state.snapshots.get(String(messageId))
161
- if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
162
- const store = state.stores.get(snap.root)
163
- if (!store) return { ok: false, error: '快照存储不可用' }
164
- const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
165
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
166
- const deleted = m ? parseInt(m[1], 10) : 0
167
- const restored = m ? parseInt(m[2], 10) : 0
168
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
169
- }
170
-
171
- // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
172
- function scanCutSeq(events, messageId) {
173
- let anchor = -1
174
- for (let i = 0; i < events.length; i++) {
175
- const e = events[i]
176
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
177
- anchor = i
178
- break
179
- }
180
- }
181
- if (anchor < 0) return null
182
- for (let i = anchor - 1; i >= 0; i--) {
183
- const e = events[i]
184
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
185
- }
186
- return null
187
- }
188
-
189
- // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
190
- // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
191
- // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
192
- async function resolveCutSeq(sessionId, messageId) {
193
- if (!sessionId || !messageId) return null
194
- const cacheKey = String(sessionId) + '\u0000' + String(messageId)
195
- if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
196
- let result = null
197
- const live = sessions.get(sessionId)
198
- if (live && Array.isArray(live.events)) {
199
- result = scanCutSeq(live.events, messageId)
200
- } else {
201
- const query = ctx.get('sessionQuery')
202
- if (query) {
203
- try {
204
- const log = await query.readSession(sessionId)
205
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
206
- } catch (error) {
207
- result = null
208
- }
209
- }
210
- }
211
- state.cutSeqCache.set(cacheKey, result)
212
- return result
213
- }
214
-
215
- return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
216
- }
1
+ /**
2
+ * dsh-recall-plugin — 快照域(ctx 绑定的工厂,无模块级副作用)
3
+ *
4
+ * 职责:快照捕获(captureSnapshot)、索引落盘/载入/孤儿重建、
5
+ * diff 清单(diffFor)、回退执行(rollbackFor)、会话切点解析
6
+ * (resolveCutSeq)。依赖 store.js 的执行与存储层,脚本文本全部
7
+ * 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
8
+ */
9
+
10
+ export function createSnapshots(ctx, rt, config) {
11
+ const sessions = ctx.sessions
12
+ const state = rt.state
13
+ // 平台选择的脚本模板(rt.scripts = scripts.pwsh.js / scripts.posix.js):
14
+ // 两套导出同名接口但实现分属 pwsh/bash,所有调用统一走 S.*
15
+ const S = rt.scripts
16
+ // 基础排除表随调用透传给脚本模板(用户 config 可调,即时生效)
17
+ // 运行时读取而非创建时快照:设置卡片热更新 baseExcludes 后,
18
+ // 下一次快照/diff/回退立即按新排除表执行
19
+ const BASE = () => config.baseExcludes
20
+
21
+ // 连续失败熔断(issue #7):失败快照的重试既无谓地全量扫描工作区,
22
+ // 又持续写入无 tag 可达的残骸对象(实测一个下午 127GB)。连续
23
+ // FUSE_AFTER 次失败后按 BASE 起步、指数翻倍、CAP 封顶的退避跳过
24
+ // 快照——冷却期内的消息不产生快照点,换来磁盘与 CPU 止血;冷却期满
25
+ // 自动重试,成功一次即全部复位,用户修好环境(如补排除配置)后
26
+ // 无需任何手动干预。
27
+ const FUSE_AFTER = 3
28
+ const FUSE_BACKOFF_BASE_MS = 5 * 60 * 1000
29
+ const FUSE_BACKOFF_CAP_MS = 60 * 60 * 1000
30
+ const snapFailures = new Map()
31
+
32
+ // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
33
+ // 分块 / POSIX stdin,实现见 store.js)——saveIndex writeExclude
34
+ // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
35
+ async function saveIndex(root, sessionId) {
36
+ const store = state.stores.get(root)
37
+ if (!store) return
38
+ // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
39
+ // 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
40
+ // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
41
+ // 旧版本插件读新索引也只取已知字段,双向兼容。
42
+ const entries = Array.from(state.snapshots.entries())
43
+ .filter(([, s]) => s.root === root)
44
+ .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
45
+ try {
46
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
47
+ } catch (error) {
48
+ rt.recordError('recall saveIndex failed: ' + String(error))
49
+ }
50
+ }
51
+
52
+ async function loadIndex(root, sessionId) {
53
+ if (state.indexLoaded.has(root)) return
54
+ const store = state.stores.get(root)
55
+ if (!store) return
56
+ try {
57
+ const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
58
+ if (!raw) { state.indexLoaded.add(root); return }
59
+ const entries = JSON.parse(raw)
60
+ if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
61
+ for (const entry of entries) {
62
+ if (!entry || typeof entry.id !== 'string') continue
63
+ state.snapshots.set(entry.id, {
64
+ root,
65
+ time: typeof entry.time === 'number' ? entry.time : Date.now(),
66
+ sessionId: entry.sessionId || sessionId
67
+ })
68
+ }
69
+ // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
70
+ // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
71
+ // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
72
+ state.indexLoaded.add(root)
73
+ } catch (error) {
74
+ /* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
75
+ }
76
+ }
77
+
78
+ // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
79
+ // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
80
+ // 文件都输出空串,这里不用区分「没配过」和「配了空」。
81
+ async function readExclude(store) {
82
+ return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
83
+ }
84
+
85
+ // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
86
+ // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
87
+ // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
88
+ async function writeExclude(store, text) {
89
+ const body = String(text == null ? '' : text)
90
+ const sep = rt.isWin ? '\\' : '/'
91
+ const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
92
+ await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
93
+ await rt.writeTextViaShell(store.excludeFile, body)
94
+ }
95
+
96
+ // 索引丢失时从仓库 tag 重建:tag snap-<messageId> 本身就是快照主键
97
+ async function rebuildOrphans(root, sessionId) {
98
+ const store = state.stores.get(root)
99
+ const gitExe = await rt.resolveGit()
100
+ if (!store || !gitExe) return
101
+ try {
102
+ const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
103
+ if (!listing) return
104
+ for (const name of listing.split(/\r?\n/)) {
105
+ const id = name.trim().replace(/^snap-/, '')
106
+ if (!id || state.snapshots.has(id)) continue
107
+ state.snapshots.set(id, { root, time: 0, sessionId })
108
+ }
109
+ await saveIndex(root, sessionId)
110
+ } catch (error) {
111
+ rt.recordError('recall rebuildOrphans failed: ' + String(error))
112
+ }
113
+ }
114
+
115
+ async function captureSnapshot(sessionId, messageId, time) {
116
+ const root = await rt.resolveRoot(sessionId)
117
+ if (!root) return
118
+ // 熔断检查要在任何解析/建仓动作之前:冷却期内连 resolveStore 都
119
+ // 不必跑,把失败重试的开销也一并止住
120
+ const fused = snapFailures.get(root)
121
+ if (fused && Date.now() < fused.skipUntil) return
122
+ let store = await rt.resolveStore(root)
123
+ store = await rt.tryUpgradeToHome(root)
124
+ const ok = await rt.ensureGit(root, store)
125
+ if (!ok) return
126
+ await loadIndex(root, sessionId)
127
+ try {
128
+ await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
129
+ snapFailures.delete(root)
130
+ state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
131
+ await saveIndex(root, sessionId)
132
+ } catch (error) {
133
+ rt.recordError('recall snapshot failed: ' + String(error))
134
+ await handleSnapshotFailure(root, store)
135
+ }
136
+ }
137
+
138
+ // 失败善后 = 清残骸 + 推进熔断。captureSnapshot 的调用方就是串行队列
139
+ // (见 index.js 事件接线),这两个动作留在 catch 里顺势排队执行,
140
+ // 与下一次快照天然互斥,无 git 锁竞态;自身整体 best-effort,
141
+ // 清理失败不该让队列任务以异常收场。
142
+ async function handleSnapshotFailure(root, store) {
143
+ if (store && state.gitExe) {
144
+ try {
145
+ await rt.runShell(S.pruneScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
146
+ } catch (error) {
147
+ rt.recordError('recall prune after snapshot failure failed: ' + String(error))
148
+ }
149
+ }
150
+ const f = snapFailures.get(root) || { count: 0, skipUntil: 0 }
151
+ f.count++
152
+ if (f.count >= FUSE_AFTER) {
153
+ const backoff = Math.min(FUSE_BACKOFF_BASE_MS * 2 ** (f.count - FUSE_AFTER), FUSE_BACKOFF_CAP_MS)
154
+ const wasFused = Date.now() < f.skipUntil
155
+ f.skipUntil = Date.now() + backoff
156
+ // 只在「未熔断→熔断」的跳变沿记录:熔断期间每条消息都会走上面的
157
+ // 静默跳过分支,逐条记录会把最近错误环形缓冲刷成同一条目
158
+ if (!wasFused) rt.recordError('recall snapshot fused after ' + f.count + ' consecutive failures, backoff ' + Math.round(backoff / 60000) + 'min for: ' + root)
159
+ }
160
+ snapFailures.set(root, f)
161
+ }
162
+
163
+ // POSIX diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
164
+ // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
165
+ function parseChanges(text) {
166
+ if (rt.isWin) {
167
+ const parsed = JSON.parse(text)
168
+ if (Array.isArray(parsed)) return parsed
169
+ if (parsed && typeof parsed === 'object') return [parsed]
170
+ return []
171
+ }
172
+ const out = []
173
+ for (const line of text.split(/\r?\n/)) {
174
+ if (!line) continue
175
+ const tab = line.indexOf('\t')
176
+ if (tab < 0) continue
177
+ out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
178
+ }
179
+ return out
180
+ }
181
+
182
+ // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM JSON
183
+ // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
184
+ // 汇总展示;total 保留完整计数让面板文案仍准确。
185
+ const MAX_CHANGES = 500
186
+
187
+ async function diffFor(messageId) {
188
+ const snap = state.snapshots.get(String(messageId))
189
+ if (!snap) return null
190
+ const store = state.stores.get(snap.root)
191
+ if (!store) return null
192
+ // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
193
+ // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
194
+ const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
195
+ const trimmed = text.trim()
196
+ if (!trimmed) return { changes: [], total: 0, truncated: false }
197
+ const all = parseChanges(trimmed)
198
+ return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
199
+ }
200
+
201
+ async function rollbackFor(messageId) {
202
+ const snap = state.snapshots.get(String(messageId))
203
+ if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
204
+ const store = state.stores.get(snap.root)
205
+ if (!store) return { ok: false, error: '快照存储不可用' }
206
+ const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
207
+ const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
208
+ const deleted = m ? parseInt(m[1], 10) : 0
209
+ const restored = m ? parseInt(m[2], 10) : 0
210
+ return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
211
+ }
212
+
213
+ // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
214
+ function scanCutSeq(events, messageId) {
215
+ let anchor = -1
216
+ for (let i = 0; i < events.length; i++) {
217
+ const e = events[i]
218
+ if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
219
+ anchor = i
220
+ break
221
+ }
222
+ }
223
+ if (anchor < 0) return null
224
+ for (let i = anchor - 1; i >= 0; i--) {
225
+ const e = events[i]
226
+ if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
227
+ }
228
+ return null
229
+ }
230
+
231
+ // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
232
+ // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
233
+ // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
234
+ async function resolveCutSeq(sessionId, messageId) {
235
+ if (!sessionId || !messageId) return null
236
+ const cacheKey = String(sessionId) + '\u0000' + String(messageId)
237
+ if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
238
+ let result = null
239
+ const live = sessions.get(sessionId)
240
+ if (live && Array.isArray(live.events)) {
241
+ result = scanCutSeq(live.events, messageId)
242
+ } else {
243
+ const query = ctx.get('sessionQuery')
244
+ if (query) {
245
+ try {
246
+ const log = await query.readSession(sessionId)
247
+ result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
248
+ } catch (error) {
249
+ result = null
250
+ }
251
+ }
252
+ }
253
+ state.cutSeqCache.set(cacheKey, result)
254
+ return result
255
+ }
256
+
257
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
258
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {