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/CHANGELOG.md +109 -0
- package/README.en.md +124 -118
- package/README.md +125 -119
- package/cordis.patch.yml +19 -11
- package/lib/client.js +780 -456
- package/lib/config.js +36 -0
- package/lib/index.js +541 -164
- package/lib/maintenance.js +120 -107
- package/lib/scripts.posix.js +366 -290
- package/lib/scripts.pwsh.js +432 -334
- package/lib/snapshots.js +214 -192
- package/lib/store.js +342 -249
- package/package.json +52 -57
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
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
}
|