dsh-recall-plugin 1.2.2 → 1.4.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/snapshots.js CHANGED
@@ -1,192 +1,214 @@
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) {
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
-
17
- // 索引落盘。win32:base64 分块内联(见 scripts.pwsh.js indexWriteCmd 注释,
18
- // 受 Windows 命令行 32767 字符上限约束)。POSIX:官方 ShellExecRequest
19
- // stdin 契约字段直写全文——不经命令行传参,没有 argv 长度上限,
20
- // 也省掉 base64 往返;单次调用即完成。
21
- async function saveIndex(root, sessionId) {
22
- const store = state.stores.get(root)
23
- if (!store) return
24
- const entries = Array.from(state.snapshots.entries())
25
- .filter(([, s]) => s.root === root)
26
- .map(([id, s]) => ({ id, time: s.time, count: s.count, sessionId: s.sessionId }))
27
- const json = JSON.stringify(entries)
28
- try {
29
- if (rt.isWin) {
30
- const b64 = Buffer.from(json, 'utf8').toString('base64')
31
- let first = true
32
- for (let i = 0; i < b64.length; i += 20000) {
33
- const piece = "[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64.slice(i, i + 20000) + "')) | "
34
- await rt.runShell(S.indexWriteCmd(store.dir, piece, first), { stdoutMaxBytes: 4096 })
35
- first = false
36
- }
37
- } else {
38
- await rt.runShell('cat > ' + S.psq(store.dir + '/index.json'), { stdin: json, stdoutMaxBytes: 4096 })
39
- }
40
- } catch (error) {
41
- console.error('recall saveIndex failed:', String(error))
42
- }
43
- }
44
-
45
- async function loadIndex(root, sessionId) {
46
- if (state.indexLoaded.has(root)) return
47
- state.indexLoaded.add(root)
48
- const store = state.stores.get(root)
49
- if (!store) return
50
- try {
51
- const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
52
- if (!raw) return
53
- const entries = JSON.parse(raw)
54
- if (!Array.isArray(entries)) return
55
- for (const entry of entries) {
56
- if (!entry || typeof entry.id !== 'string') continue
57
- state.snapshots.set(entry.id, {
58
- root,
59
- time: typeof entry.time === 'number' ? entry.time : Date.now(),
60
- count: typeof entry.count === 'number' ? entry.count : 0,
61
- sessionId: entry.sessionId || sessionId
62
- })
63
- }
64
- } catch (error) {
65
- /* 索引缺失或损坏时按空历史处理 */
66
- }
67
- }
68
-
69
- // 索引丢失时从仓库 tag 重建:tag snap-<messageId> 本身就是快照主键
70
- async function rebuildOrphans(root, sessionId) {
71
- const store = state.stores.get(root)
72
- const gitExe = await rt.resolveGit()
73
- if (!store || !gitExe) return
74
- try {
75
- const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
76
- if (!listing) return
77
- for (const name of listing.split(/\r?\n/)) {
78
- const id = name.trim().replace(/^snap-/, '')
79
- if (!id || state.snapshots.has(id)) continue
80
- state.snapshots.set(id, { root, time: 0, count: 0, sessionId })
81
- }
82
- await saveIndex(root, sessionId)
83
- } catch (error) {
84
- console.error('recall rebuildOrphans failed:', String(error))
85
- }
86
- }
87
-
88
- async function captureSnapshot(sessionId, messageId, time) {
89
- const root = await rt.resolveRoot(sessionId)
90
- if (!root) return
91
- let store = await rt.resolveStore(root)
92
- store = await rt.tryUpgradeToHome(root)
93
- const ok = await rt.ensureGit(root, store)
94
- if (!ok) return
95
- await loadIndex(root, sessionId)
96
- try {
97
- await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
98
- state.snapshots.set(String(messageId), { root, time: time || Date.now(), count: 0, sessionId })
99
- await saveIndex(root, sessionId)
100
- } catch (error) {
101
- console.error('recall snapshot failed:', String(error))
102
- }
103
- }
104
-
105
- // POSIX diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
106
- // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
107
- function parseChanges(text) {
108
- if (rt.isWin) {
109
- const parsed = JSON.parse(text)
110
- if (Array.isArray(parsed)) return parsed
111
- if (parsed && typeof parsed === 'object') return [parsed]
112
- return []
113
- }
114
- const out = []
115
- for (const line of text.split(/\r?\n/)) {
116
- if (!line) continue
117
- const tab = line.indexOf('\t')
118
- if (tab < 0) continue
119
- out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
120
- }
121
- return out
122
- }
123
-
124
- async function diffFor(messageId) {
125
- const snap = state.snapshots.get(String(messageId))
126
- if (!snap) return null
127
- const store = state.stores.get(snap.root)
128
- if (!store) return null
129
- const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 4194304 }))
130
- const trimmed = text.trim()
131
- if (!trimmed) return []
132
- return parseChanges(trimmed)
133
- }
134
-
135
- async function rollbackFor(messageId) {
136
- const snap = state.snapshots.get(String(messageId))
137
- if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
138
- const store = state.stores.get(snap.root)
139
- if (!store) return { ok: false, error: '快照存储不可用' }
140
- const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
141
- const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
142
- const deleted = m ? parseInt(m[1], 10) : 0
143
- const restored = m ? parseInt(m[2], 10) : 0
144
- return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
145
- }
146
-
147
- // 在事件序列里找“该消息之前最近一次 turn/end 的 seq”。
148
- function scanCutSeq(events, messageId) {
149
- let anchor = -1
150
- for (let i = 0; i < events.length; i++) {
151
- const e = events[i]
152
- if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
153
- anchor = i
154
- break
155
- }
156
- }
157
- if (anchor < 0) return null
158
- for (let i = anchor - 1; i >= 0; i--) {
159
- const e = events[i]
160
- if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
161
- }
162
- return null
163
- }
164
-
165
- // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
166
- // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
167
- // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
168
- async function resolveCutSeq(sessionId, messageId) {
169
- if (!sessionId || !messageId) return null
170
- const cacheKey = String(sessionId) + '\u0000' + String(messageId)
171
- if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
172
- let result = null
173
- const live = sessions.get(sessionId)
174
- if (live && Array.isArray(live.events)) {
175
- result = scanCutSeq(live.events, messageId)
176
- } else {
177
- const query = ctx.get('sessionQuery')
178
- if (query) {
179
- try {
180
- const log = await query.readSession(sessionId)
181
- result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
182
- } catch (error) {
183
- result = null
184
- }
185
- }
186
- }
187
- state.cutSeqCache.set(cacheKey, result)
188
- return result
189
- }
190
-
191
- return { saveIndex, loadIndex, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
192
- }
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
+ const BASE = config.baseExcludes
18
+
19
+ // 索引落盘:任意长度文本统一走 rt.writeTextViaShell(win32 base64
20
+ // 分块 / POSIX stdin,实现见 store.js)——saveIndex 与 writeExclude
21
+ // 曾逐字重复这套平台分叉,改一处漏一处的风险随合并消失。
22
+ async function saveIndex(root, sessionId) {
23
+ const store = state.stores.get(root)
24
+ if (!store) return
25
+ // 每条带 root:设置页「快照管理」要跨工作区展示列表,而 store 目录名
26
+ // root 的单向哈希、反解不了——index.json 是唯一能持久「哈希↔工作区
27
+ // 路径」对应关系的地方。loadIndex 忽略 entry.root(以参数为准),
28
+ // 旧版本插件读新索引也只取已知字段,双向兼容。
29
+ const entries = Array.from(state.snapshots.entries())
30
+ .filter(([, s]) => s.root === root)
31
+ .map(([id, s]) => ({ id, time: s.time, root: s.root, sessionId: s.sessionId }))
32
+ try {
33
+ await rt.writeTextViaShell(store.dir + (rt.isWin ? '\\' : '/') + 'index.json', JSON.stringify(entries))
34
+ } catch (error) {
35
+ rt.recordError('recall saveIndex failed: ' + String(error))
36
+ }
37
+ }
38
+
39
+ async function loadIndex(root, sessionId) {
40
+ if (state.indexLoaded.has(root)) return
41
+ const store = state.stores.get(root)
42
+ if (!store) return
43
+ try {
44
+ const raw = S.stripBom(await rt.runShell(S.indexReadCmd(store.dir), { stdoutMaxBytes: 4194304 })).trim()
45
+ if (!raw) { state.indexLoaded.add(root); return }
46
+ const entries = JSON.parse(raw)
47
+ if (!Array.isArray(entries)) { state.indexLoaded.add(root); return }
48
+ for (const entry of entries) {
49
+ if (!entry || typeof entry.id !== 'string') continue
50
+ state.snapshots.set(entry.id, {
51
+ root,
52
+ time: typeof entry.time === 'number' ? entry.time : Date.now(),
53
+ sessionId: entry.sessionId || sessionId
54
+ })
55
+ }
56
+ // 只在读取链路全部走通后才标记已载入:若在 try 前抢先标记,
57
+ // runShell 失败(shell 未就绪等)被吞后该 root 本次进程内被永久
58
+ // 视为「已载入」,索引永远为空、撤回按钮消失直到重启 DSH。
59
+ state.indexLoaded.add(root)
60
+ } catch (error) {
61
+ /* 索引缺失或损坏时按空历史处理;不标记已载入,下次自然重试 */
62
+ }
63
+ }
64
+
65
+ // exclude.txt 原文读取(设置页编辑用):stripBom 剥掉 PS 5.1 Set-Content
66
+ // 写入的 UTF-8 BOM,避免设置页首行出现不可见的 \uFEFF;两套模板对缺失
67
+ // 文件都输出空串,这里不用区分「没配过」和「配了空」。
68
+ async function readExclude(store) {
69
+ return S.stripBom(await rt.runShell(S.excludeReadCmd(store.excludeFile), { stdoutMaxBytes: 1048576 }))
70
+ }
71
+
72
+ // exclude.txt 原文写入(设置页保存):先 mkdir 父目录兜底(home 根目录
73
+ // /降级 store 目录被用户手滑删掉时,保存不该因此失败),写本体统一走
74
+ // rt.writeTextViaShell,与 saveIndex 共用同一套平台分叉原语。
75
+ async function writeExclude(store, text) {
76
+ const body = String(text == null ? '' : text)
77
+ const sep = rt.isWin ? '\\' : '/'
78
+ const parent = store.excludeFile.slice(0, store.excludeFile.lastIndexOf(sep))
79
+ await rt.runShell(S.mkdirScript(parent), { stdoutMaxBytes: 4096 })
80
+ await rt.writeTextViaShell(store.excludeFile, body)
81
+ }
82
+
83
+ // 索引丢失时从仓库 tag 重建:tag 名 snap-<messageId> 本身就是快照主键
84
+ async function rebuildOrphans(root, sessionId) {
85
+ const store = state.stores.get(root)
86
+ const gitExe = await rt.resolveGit()
87
+ if (!store || !gitExe) return
88
+ try {
89
+ const listing = S.stripBom(await rt.runShell(S.listTagsScript(store, gitExe), { stdoutMaxBytes: 4194304 })).trim()
90
+ if (!listing) return
91
+ for (const name of listing.split(/\r?\n/)) {
92
+ const id = name.trim().replace(/^snap-/, '')
93
+ if (!id || state.snapshots.has(id)) continue
94
+ state.snapshots.set(id, { root, time: 0, sessionId })
95
+ }
96
+ await saveIndex(root, sessionId)
97
+ } catch (error) {
98
+ rt.recordError('recall rebuildOrphans failed: ' + String(error))
99
+ }
100
+ }
101
+
102
+ async function captureSnapshot(sessionId, messageId, time) {
103
+ const root = await rt.resolveRoot(sessionId)
104
+ if (!root) return
105
+ let store = await rt.resolveStore(root)
106
+ store = await rt.tryUpgradeToHome(root)
107
+ const ok = await rt.ensureGit(root, store)
108
+ if (!ok) return
109
+ await loadIndex(root, sessionId)
110
+ try {
111
+ await rt.runShell(S.snapshotScript(root, store, state.gitExe, messageId, BASE), { timeoutMs: 600000, stdoutMaxBytes: 65536 })
112
+ state.snapshots.set(String(messageId), { root, time: time || Date.now(), sessionId })
113
+ await saveIndex(root, sessionId)
114
+ } catch (error) {
115
+ rt.recordError('recall snapshot failed: ' + String(error))
116
+ }
117
+ }
118
+
119
+ // POSIX diff 输出是 TSV「kind<TAB>path」逐行(bash 模板不拼 JSON,
120
+ // 避免 jq 依赖与转义坑);win32 侧是 ConvertTo-Json。这里按平台分叉解析。
121
+ function parseChanges(text) {
122
+ if (rt.isWin) {
123
+ const parsed = JSON.parse(text)
124
+ if (Array.isArray(parsed)) return parsed
125
+ if (parsed && typeof parsed === 'object') return [parsed]
126
+ return []
127
+ }
128
+ const out = []
129
+ for (const line of text.split(/\r?\n/)) {
130
+ if (!line) continue
131
+ const tab = line.indexOf('\t')
132
+ if (tab < 0) continue
133
+ out.push({ kind: line.slice(0, tab), rel: line.slice(tab + 1) })
134
+ }
135
+ return out
136
+ }
137
+
138
+ // 变更清单截断上限:防止超大工作区(几千个文件)把 DOM 与 JSON
139
+ // 双双撑爆。清单对用户的价值集中在前若干条,其余以 truncated 计数
140
+ // 汇总展示;total 保留完整计数让面板文案仍准确。
141
+ const MAX_CHANGES = 500
142
+
143
+ async function diffFor(messageId) {
144
+ const snap = state.snapshots.get(String(messageId))
145
+ if (!snap) return null
146
+ const store = state.stores.get(snap.root)
147
+ if (!store) return null
148
+ // 8MB 上限:按平均每条 60 字节估算可容纳十余万条,正常项目远够;
149
+ // 真超限时报错文案与「JSON 半截解析失败」的真实原因脱节,需显式检测
150
+ const text = S.stripBom(await rt.runShell(S.diffScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE), { timeoutMs: 600000, stdoutMaxBytes: 8388608 }))
151
+ const trimmed = text.trim()
152
+ if (!trimmed) return { changes: [], total: 0, truncated: false }
153
+ const all = parseChanges(trimmed)
154
+ return { changes: all.slice(0, MAX_CHANGES), total: all.length, truncated: all.length > MAX_CHANGES }
155
+ }
156
+
157
+ async function rollbackFor(messageId) {
158
+ const snap = state.snapshots.get(String(messageId))
159
+ if (!snap) return { ok: false, error: '该消息没有可用的项目快照' }
160
+ const store = state.stores.get(snap.root)
161
+ if (!store) return { ok: false, error: '快照存储不可用' }
162
+ const text = S.stripBom(await rt.runShell(S.rollbackScript(snap.root, store, state.gitExe, 'snap-' + messageId, BASE), { timeoutMs: 600000, stdoutMaxBytes: 65536 }))
163
+ const m = text.trim().match(/^ROLLBACK_OK\s+(\d+)\s+(\d+)/)
164
+ const deleted = m ? parseInt(m[1], 10) : 0
165
+ const restored = m ? parseInt(m[2], 10) : 0
166
+ return { ok: true, count: (Number.isNaN(deleted) ? 0 : deleted) + (Number.isNaN(restored) ? 0 : restored) }
167
+ }
168
+
169
+ // 在事件序列里找“该消息之前最近一次 turn/end seq”。
170
+ function scanCutSeq(events, messageId) {
171
+ let anchor = -1
172
+ for (let i = 0; i < events.length; i++) {
173
+ const e = events[i]
174
+ if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
175
+ anchor = i
176
+ break
177
+ }
178
+ }
179
+ if (anchor < 0) return null
180
+ for (let i = anchor - 1; i >= 0; i--) {
181
+ const e = events[i]
182
+ if (e && e.type === 'turn/end' && typeof e.seq === 'number') return e.seq
183
+ }
184
+ return null
185
+ }
186
+
187
+ // 解析“整段回退”的会话切点:优先读 live 会话的内存事件(零 IO、毫秒级),
188
+ // 冷会话回退到 sessionQuery.readSession;结果按 (会话, 消息) 缓存——
189
+ // 消息一旦入日志,其之前的 turn/end 永不变化,缓存终身有效。
190
+ async function resolveCutSeq(sessionId, messageId) {
191
+ if (!sessionId || !messageId) return null
192
+ const cacheKey = String(sessionId) + '\u0000' + String(messageId)
193
+ if (state.cutSeqCache.has(cacheKey)) return state.cutSeqCache.get(cacheKey)
194
+ let result = null
195
+ const live = sessions.get(sessionId)
196
+ if (live && Array.isArray(live.events)) {
197
+ result = scanCutSeq(live.events, messageId)
198
+ } else {
199
+ const query = ctx.get('sessionQuery')
200
+ if (query) {
201
+ try {
202
+ const log = await query.readSession(sessionId)
203
+ result = scanCutSeq(Array.isArray(log && log.events) ? log.events : [], messageId)
204
+ } catch (error) {
205
+ result = null
206
+ }
207
+ }
208
+ }
209
+ state.cutSeqCache.set(cacheKey, result)
210
+ return result
211
+ }
212
+
213
+ return { saveIndex, loadIndex, readExclude, writeExclude, rebuildOrphans, captureSnapshot, diffFor, rollbackFor, resolveCutSeq }
214
+ }