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.
@@ -1,145 +1,263 @@
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
+
20
+ // P1-3 纯逻辑:按 root 分组选出超限部分的最旧快照(time 升序,time=0 孤儿
21
+ // 最旧优先),模块级导出供 tests/unit 直接钉边界;工厂内 enforceLimits 复用。
22
+ export function selectOverLimitVictims(snapshots, limit) {
23
+ if (!limit || limit <= 0) return new Map() // 0 或负值 = 不限制
24
+ const byRoot = new Map()
25
+ for (const [id, s] of snapshots.entries()) {
26
+ if (!s || !s.root) continue
27
+ if (!byRoot.has(s.root)) byRoot.set(s.root, [])
28
+ byRoot.get(s.root).push({ id, time: s.time })
29
+ }
30
+ const victims = new Map()
31
+ for (const [root, list] of byRoot) {
32
+ if (list.length <= limit) continue
33
+ const excess = list.length - limit
34
+ // 按时间升序排:time=0 最旧,优先清;同时间保插入序(先入先出)
35
+ list.sort((a, b) => (a.time || 0) - (b.time || 0))
36
+ victims.set(root, list.slice(0, excess))
37
+ }
38
+ return victims
39
+ }
40
+
41
+ // S2-3 按时间保留的纯逻辑:retentionDays <= 0 不启用;按 root 分组,
42
+ // 命中「time > 0 且早于 cutoff」的入选(time=0 孤儿视为最旧,一并最先
43
+ // 清——与 selectOverLimitVictims 同构)。模块级导出供单测钉边界。
44
+ export function selectExpiredVictims(snapshots, retentionDays, now) {
45
+ if (!retentionDays || retentionDays <= 0) return new Map()
46
+ const cutoff = (typeof now === 'number' ? now : Date.now()) - retentionDays * 86400000
47
+ const byRoot = new Map()
48
+ for (const [id, s] of snapshots.entries()) {
49
+ if (!s || !s.root) continue
50
+ // time=0 孤儿(rebuildOrphans 重建)无真实时间,视为最旧列入
51
+ if (s.time > 0 && s.time >= cutoff) continue
52
+ if (!byRoot.has(s.root)) byRoot.set(s.root, [])
53
+ byRoot.get(s.root).push({ id, time: s.time })
54
+ }
55
+ return byRoot
56
+ }
57
+
58
+ export function createMaintenance(ctx, rt, snaps, config) {
59
+ const sessions = ctx.sessions
60
+ const state = rt.state
61
+ // 平台选择的脚本模板(gc/purge 两套模板同名导出)
62
+ const S = rt.scripts
63
+
64
+ // 删除一个会话的全部快照:按 root 分组(同一会话可能换过工作目录),
65
+ // tag 分块删除规避命令行长度上限,索引重写交给 snaps.saveIndex。
66
+ // best-effort:单块失败只记日志,剩余块继续;tag 残留由下次清理幂等收尾。
67
+ async function purgeSession(sessionId) {
68
+ const byRoot = new Map()
69
+ for (const [id, s] of state.snapshots.entries()) {
70
+ if (!s || s.sessionId !== sessionId) continue
71
+ if (!byRoot.has(s.root)) byRoot.set(s.root, [])
72
+ byRoot.get(s.root).push(id)
73
+ }
74
+ let purged = 0
75
+ for (const [root, ids] of byRoot) {
76
+ let store = state.stores.get(root)
77
+ if (!store) {
78
+ // 冷启动时 store 缓存可能还没建:现场解析一次而不是直接跳过——
79
+ // 跳过会让该 root 的快照永远清不掉(sweep 每轮都 miss)
80
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
81
+ }
82
+ if (!store || !state.gitExe) continue
83
+ try {
84
+ for (let i = 0; i < ids.length; i += 100) {
85
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, ids.slice(i, i + 100).map((id) => 'snap-' + id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
86
+ }
87
+ for (const id of ids) state.snapshots.delete(id)
88
+ await snaps.saveIndex(root, sessionId)
89
+ purged += ids.length
90
+ } catch (error) {
91
+ rt.recordError('recall purge session failed: ' + String(error))
92
+ }
93
+ }
94
+ if (purged > 0) console.error('recall purged snapshots of deleted session:', sessionId, purged)
95
+ return purged
96
+ }
97
+
98
+ // 扫描索引里出现过的全部会话:既不在 sessions 注册表、冷读日志又失败的,
99
+ // 才认定「已删除」。两个保守闸门:
100
+ // - sessionQuery 服务不存在时整体跳过——没有冷读能力就无法区分
101
+ // 「已删除」和「只是冷着」,误删快照不可逆,宁可不清理;
102
+ // - 归档会话(撤回功能自己归档的)日志仍在磁盘上,readSession 仍成功,
103
+ // 不会被误清——只有日志真正消失才触发。
104
+ async function sweepDeletedSessions() {
105
+ const ids = new Set()
106
+ for (const s of state.snapshots.values()) {
107
+ if (s && s.sessionId) ids.add(s.sessionId)
108
+ }
109
+ if (!ids.size) return
110
+ const query = ctx.get('sessionQuery')
111
+ if (!query || typeof query.readSession !== 'function') return
112
+ for (const id of ids) {
113
+ if (sessions.get(id)) continue
114
+ let alive = false
115
+ try {
116
+ const log = await query.readSession(id)
117
+ alive = Boolean(log)
118
+ } catch (error) {
119
+ alive = false
120
+ }
121
+ if (!alive) await purgeSession(id)
122
+ }
123
+ }
124
+
125
+ // 存储总量上限(P1-3):按 root 分组统计,超限按 time 升序删最旧。
126
+ // 调用点(runGc/runGcAll)都在串行队列里,与快照互斥,无 git 锁竞态。
127
+ // 删除前 console.error 留痕(静默删历史撤回点必须可追溯,与 purgeSession
128
+ // 同款);删除后重写索引。time=0 的孤儿条目(rebuildOrphans 重建)视为
129
+ // 最旧优先清理——它们没有真实时间,先于有时间的快照被清。
130
+ // 分组与选中逻辑在模块级 selectOverLimitVictims(单测直接钉边界)。
131
+ async function enforceLimits() {
132
+ const victimsMap = selectOverLimitVictims(state.snapshots, config.maxSnapshotsPerWorkspace)
133
+ if (!victimsMap.size) return 0
134
+ let dropped = 0
135
+ for (const [root, victims] of victimsMap) {
136
+ let store = state.stores.get(root)
137
+ if (!store) {
138
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
139
+ }
140
+ if (!store || !state.gitExe) continue
141
+ try {
142
+ for (let i = 0; i < victims.length; i += 100) {
143
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
144
+ }
145
+ for (const v of victims) state.snapshots.delete(v.id)
146
+ await snaps.saveIndex(root, null)
147
+ dropped += victims.length
148
+ console.error('recall enforceLimits dropped ' + victims.length + ' oldest snapshots for: ' + root + ' (max ' + config.maxSnapshotsPerWorkspace + ')')
149
+ } catch (error) {
150
+ rt.recordError('recall enforceLimits failed for ' + root + ': ' + String(error))
151
+ }
152
+ }
153
+ return dropped
154
+ }
155
+
156
+ // 按时间保留(S2-3):对每个 root 清掉早于保留窗口的快照。结构与
157
+ // enforceLimits 同款(tag 分块删除 + saveIndex + 留痕),与条数上限
158
+ // 各自独立触发,在同一轮 gc 周期里先后执行。时间维度的删除同样
159
+ // 静默丢历史撤回点,故 console.error 留痕与 enforceLimits 一致。
160
+ async function enforceRetention() {
161
+ const victimsMap = selectExpiredVictims(state.snapshots, config.retentionDays, Date.now())
162
+ if (!victimsMap.size) return 0
163
+ let dropped = 0
164
+ for (const [root, victims] of victimsMap) {
165
+ let store = state.stores.get(root)
166
+ if (!store) {
167
+ try { store = await rt.resolveStore(root) } catch (error) { store = null }
168
+ }
169
+ if (!store || !state.gitExe) continue
170
+ try {
171
+ for (let i = 0; i < victims.length; i += 100) {
172
+ await rt.runShell(S.purgeTagsScript(store, state.gitExe, victims.slice(i, i + 100).map((v) => 'snap-' + v.id)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
173
+ }
174
+ for (const v of victims) state.snapshots.delete(v.id)
175
+ await snaps.saveIndex(root, null)
176
+ dropped += victims.length
177
+ console.error('recall enforceRetention dropped ' + victims.length + ' expired snapshots for: ' + root + ' (retention ' + config.retentionDays + 'd)')
178
+ } catch (error) {
179
+ rt.recordError('recall enforceRetention failed for ' + root + ': ' + String(error))
180
+ }
181
+ }
182
+ return dropped
183
+ }
184
+
185
+ // 维护核心(节流判定 + 清理 + gc):force 供设置页「立即 gc」手动触发,
186
+ // 跳过阈值检查但仍走同一条串行队列调用方——与快照天然互斥的约束不变。
187
+ // 失败也推进 gcLastAt:gc 失败往往是环境性的(磁盘/杀软),不推进时间戳
188
+ // 会让后续每条消息都重试一次重量级 gc,把队列堵住。
189
+ async function runGc(sessionId, force) {
190
+ const root = await rt.resolveRoot(sessionId)
191
+ if (!root) return false
192
+ const store = state.stores.get(root)
193
+ if (!store || !state.gitExe) return false
194
+ const now = Date.now()
195
+ const last = state.gcLastAt.get(store.git) || 0
196
+ const count = (state.gcCount.get(store.git) || 0) + 1
197
+ state.gcCount.set(store.git, count)
198
+ if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
199
+ state.gcCount.set(store.git, 0)
200
+ try {
201
+ await sweepDeletedSessions()
202
+ // P1-3:总量上限清理(sweep 之后、gc 之前)——与快照在同一条串行
203
+ // 队列里,删除 tag 与 gc 互斥,无 git 锁竞态;best-effort,
204
+ // 自身失败不进 catch 的主错误路径(enforceLimits 内部已兜)。
205
+ await enforceLimits()
206
+ // S2-3:按时间保留清理(与条数上限维度各自独立,同条串行队列)
207
+ await enforceRetention()
208
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
209
+ } catch (error) {
210
+ rt.recordError('recall maintenance failed: ' + String(error))
211
+ }
212
+ state.gcLastAt.set(store.git, Date.now())
213
+ return true
214
+ }
215
+
216
+ // 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
217
+ // store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
218
+ // best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
219
+ // 串行队列,与快照天然互斥,无 git 锁竞态。
220
+ async function runGcAll() {
221
+ const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
222
+ if (!stores.length || !state.gitExe) return false
223
+ try {
224
+ await sweepDeletedSessions()
225
+ } catch (error) {
226
+ rt.recordError('recall sweep failed: ' + String(error))
227
+ }
228
+ try {
229
+ // P1-3:全局清理一次(runGcAll 无会话上下文,enforceLimits 自身按
230
+ // root 遍历内存快照,天然覆盖全部已知工作区)
231
+ await enforceLimits()
232
+ } catch (error) {
233
+ rt.recordError('recall enforceLimits failed: ' + String(error))
234
+ }
235
+ try {
236
+ // S2-3:按时间保留全局清理一次
237
+ await enforceRetention()
238
+ } catch (error) {
239
+ rt.recordError('recall enforceRetention failed: ' + String(error))
240
+ }
241
+ let done = 0
242
+ for (const store of stores) {
243
+ try {
244
+ await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
245
+ done++
246
+ } catch (error) {
247
+ rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
248
+ }
249
+ state.gcLastAt.set(store.git, Date.now())
250
+ state.gcCount.set(store.git, 0)
251
+ }
252
+ return true
253
+ }
254
+
255
+ // 每条消息快照后串行调用(见 index.js 事件接线)
256
+ async function maybeMaintain(sessionId) {
257
+ await runGc(sessionId, false)
258
+ }
259
+
260
+ // 模块收敛:runGc/runGcAll 之外的内部步骤不对外暴露面;enforceLimits
261
+ // 保留导出供单测以工厂形态驱动(注入假 rt/ctx 钉执行链路)
262
+ return { maybeMaintain, runGc, runGcAll, enforceLimits, enforceRetention }
263
+ }
package/lib/snapshots.js CHANGED
@@ -7,6 +7,58 @@
7
7
  * 来自 rt.scripts(按平台选择的 scripts.pwsh.js / scripts.posix.js)。
8
8
  */
9
9
 
10
+ // ---- 纯逻辑(模块级导出,供 tests/unit 直接钉住;工厂内沿用同一实现)----
11
+
12
+ // 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
13
+ // 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
14
+ // 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
15
+ export function parseSkipped(out) {
16
+ const skipped = []
17
+ for (const line of String(out || '').split(/\r?\n/)) {
18
+ if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
19
+ }
20
+ return skipped
21
+ }
22
+
23
+ // POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
24
+ // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
25
+ export function parseChanges(text, isWin) {
26
+ if (isWin) {
27
+ const parsed = JSON.parse(text)
28
+ if (Array.isArray(parsed)) return parsed
29
+ if (parsed && typeof parsed === 'object') return [parsed]
30
+ return []
31
+ }
32
+ const out = []
33
+ for (const line of text.split(/\r?\n/)) {
34
+ if (!line) continue
35
+ const tab = line.indexOf('\t')
36
+ if (tab < 0) continue
37
+ out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
38
+ }
39
+ return out
40
+ }
41
+
42
+ // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
43
+ export function scanCutSeq(events, messageId) {
44
+ let anchor = -1
45
+ for (let i = 0; i < events.length; i++) {
46
+ const e = events[i]
47
+ if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
48
+ anchor = i
49
+ break
50
+ }
51
+ }
52
+ if (anchor < 0) return null
53
+ for (let i = anchor - 1; i >= 0; i--) {
54
+ const e = events[i]
55
+ if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
56
+ }
57
+ return null
58
+ }
59
+
60
+ // ---- 配置工厂 ----
61
+
10
62
  export function createSnapshots(ctx, rt, config) {
11
63
  const sessions = ctx.sessions
12
64
  const state = rt.state
@@ -39,9 +91,18 @@ export function createSnapshots(ctx, rt, config) {
39
91
  // 是 root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
40
92
  // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
41
93
  // 旧版本插件读新索引也只取已知字段,双向兼容。
94
+ // feedback 落盘(P1-2):只对「需要解释」的消息写 feedback 字段(失败/
95
+ // 有跳过),正常快照不带——省空间;重启后 snapshot-info 仍能解释
96
+ // 「这条消息为什么没有/缺了快照」。与 root 字段当年的兼容策略一致:
97
+ // 老版本插件读新索引忽略未知字段。
42
98
  const entries = Array.from(state.snapshots.entries())
43
99
  .filter(([, s]) => s.root === root)
44
- .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
100
+ .map(([id, s]) => {
101
+ const rec = { id, time: s.time, root: s.root, sessionId: s.sessionId }
102
+ const fb = state.snapFeedback.get(id)
103
+ if (fb && (fb.failed || (Array.isArray(fb.skipped) && fb.skipped.length))) rec.feedback = fb
104
+ return rec
105
+ })
45
106
  try {
46
107
  await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
47
108
  } catch (error) {
@@ -65,6 +126,16 @@ export function createSnapshots(ctx, rt, config) {
65
126
  time: typeof entry.time === 'number' ? entry.time : Date.now(),
66
127
  sessionId: entry.sessionId || sessionId
67
128
  })
129
+ // feedback 回填(P1-2):重启后仍能解释「这条消息为什么没有/缺了
130
+ // 快照」。复用 setFeedback 落内存(保持 FIFO 上限),只回填「需要
131
+ // 解释」的记录;旧版索引无 feedback 字段时天然跳过。
132
+ const fb = entry.feedback
133
+ if (fb && typeof fb === 'object') {
134
+ const rec = {}
135
+ if (fb.failed) { rec.failed = true; if (typeof fb.error === 'string') rec.error = fb.error }
136
+ if (Array.isArray(fb.skipped)) rec.skipped = fb.skipped.filter((p) => typeof p === 'string')
137
+ if (rec.failed || (Array.isArray(rec.skipped) && rec.skipped.length)) setFeedback(entry.id, rec)
138
+ }
68
139
  }
69
140
  // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
70
141
  // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
@@ -137,17 +208,6 @@ export function createSnapshots(ctx, rt, config) {
137
208
  }
138
209
  }
139
210
 
140
- // 脚本侧 fail-open 跳过的路径(--ignore-errors 下无法索引的目录,如无
141
- // 提交的嵌入式仓库)以「SNAP_SKIP <path>」行回传:这些路径不进快照,
142
- // 撤回时既不恢复也不会被删,用户应当知道快照少了什么。
143
- function parseSkipped(out) {
144
- const skipped = []
145
- for (const line of String(out || '').split(/\r?\n/)) {
146
- if (line.indexOf('SNAP_SKIP ') === 0) skipped.push(line.slice('SNAP_SKIP '.length))
147
- }
148
- return skipped
149
- }
150
-
151
211
  // 逐消息反馈写入(issue #7 失败可见性):成功无跳过 → 清除(重试成功
152
212
  // 自愈);失败/有跳过 → 记录。上限防泄漏:交替成功失败的长会话可以无限
153
213
  // 积累,Map 保插入序做 FIFO 淘汰。
@@ -202,25 +262,6 @@ export function createSnapshots(ctx, rt, config) {
202
262
  snapFailures.set(root, f)
203
263
  }
204
264
 
205
- // POSIX 侧 diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
206
- // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
207
- function parseChanges(text) {
208
- if (rt.isWin) {
209
- const parsed = JSON.parse(text)
210
- if (Array.isArray(parsed)) return parsed
211
- if (parsed && typeof parsed === 'object') return [parsed]
212
- return []
213
- }
214
- const out = []
215
- for (const line of text.split(/\r?\n/)) {
216
- if (!line) continue
217
- const tab = line.indexOf('\t')
218
- if (tab < 0) continue
219
- out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
220
- }
221
- return out
222
- }
223
-
224
265
  // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
225
266
  // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
226
267
  // 汇总展示;total 保留完整计数让面板文案仍准确。
@@ -236,7 +277,7 @@ export function createSnapshots(ctx, rt, config) {
236
277
  const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE()), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
237
278
  const trimmed = text.trim()
238
279
  if (!trimmed) return { changes: [], total: 0, truncated: false }
239
- const all = parseChanges(trimmed)
280
+ const all = parseChanges(trimmed, rt.isWin)
240
281
  return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
241
282
  }
242
283
 
@@ -252,24 +293,6 @@ export function createSnapshots(ctx, rt, config) {
252
293
  return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
253
294
  }
254
295
 
255
- // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
256
- function scanCutSeq(events, messageId) {
257
- let anchor = -1
258
- for (let i = 0; i < events.length; i++) {
259
- const e = events[i]
260
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
261
- anchor = i
262
- break
263
- }
264
- }
265
- if (anchor < 0) return null
266
- for (let i = anchor - 1; i >= 0; i--) {
267
- const e = events[i]
268
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
269
- }
270
- return null
271
- }
272
-
273
296
  // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
274
297
  // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
275
298
  // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-recall-plugin",
3
- "version": "1.7.0",
3
+ "version": "2.0.0",
4
4
  "description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "engines": {
28
28
  "node": ">=20"
29
29
  },
30
+ "scripts": {
31
+ "test": "vitest run tests/unit",
32
+ "test:probe": "vitest run tests/probe"
33
+ },
30
34
  "dsh": {
31
35
  "bundle": {
32
36
  "patch": "./cordis.patch.yml"
@@ -40,15 +44,18 @@
40
44
  },
41
45
  "peerDependencies": {
42
46
  "@deepseek-ai/cordis": "^4.0.1",
43
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
44
- "@deepseek-ai/schemastery": "^3.18.1",
45
- "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
46
- "@deepseek-ai/dsh-shell": "^0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-client-web-react": "^0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
47
49
  "@deepseek-ai/dsh-sandbox-policy": "^0.1.1-rc.2",
50
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
48
51
  "@deepseek-ai/dsh-session-query": "^0.1.1-rc.2",
49
- "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
50
- "@deepseek-ai/dsh-client-web-react": "^0.1.1-rc.2",
52
+ "@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
53
+ "@deepseek-ai/dsh-shell": "^0.1.1-rc.2",
54
+ "@deepseek-ai/schemastery": "^3.18.1",
51
55
  "react": "^18.2.0"
52
56
  },
53
- "license": "MIT"
57
+ "license": "MIT",
58
+ "devDependencies": {
59
+ "vitest": "^4.1.11"
60
+ }
54
61
  }