dsh-rewind-plugin 0.2.5 → 0.2.6
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/README.md +13 -7
- package/README.zh.md +11 -7
- package/package.json +6 -2
- package/scripts/repair-markers.mjs +176 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
In-place conversation rewind for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the Claude Code `/rewind` semantics inside the **same session window** — cut the model context back to an earlier user message, and optionally restore workspace files from **disk-persisted before-backups**.
|
|
6
6
|
|
|
7
|
-
> **Status:** published to npm (`dsh-rewind-plugin`, v0.2.
|
|
7
|
+
> **Status:** published to npm (`dsh-rewind-plugin`, v0.2.6) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
10
|
[](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
|
|
@@ -85,20 +85,26 @@ conversation**: the marker's turn number collided with the next real turn's
|
|
|
85
85
|
`Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)`
|
|
86
86
|
and the history vanished. Rewinds created from 0.2.5 on no longer produce the
|
|
87
87
|
collision, but **already-corrupted sessions need an offline repair** (the log is
|
|
88
|
-
append-only — it cannot be rewritten in memory)
|
|
88
|
+
append-only — it cannot be rewritten in memory).
|
|
89
|
+
|
|
90
|
+
The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
|
|
91
|
+
source checkout needed:
|
|
89
92
|
|
|
90
93
|
```sh
|
|
91
94
|
# 1. Fully quit dsh web / host first (while a session is resident in memory,
|
|
92
95
|
# a disk repair is overwritten by the next checkpoint)
|
|
93
|
-
# 2. Run the offline repair
|
|
96
|
+
# 2. Run the offline repair (scans every session under ~/.dsh/sessions,
|
|
94
97
|
# rewriting each marker's turn back to the last started turn)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
node scripts/repair-markers.mjs --dir <sessions root> # custom data dir
|
|
98
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
99
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
|
|
98
100
|
# 3. Restart dsh web — the repaired sessions load their history again
|
|
99
101
|
```
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
Or install it globally once (`npm i -g dsh-rewind-plugin`) and run
|
|
104
|
+
`dsh-rewind-repair` directly; from a source checkout the same tool is
|
|
105
|
+
`node scripts/repair-markers.mjs` (identical flags).
|
|
106
|
+
|
|
107
|
+
The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
|
|
102
108
|
(keeping seqs, order, and the zstd frame structure intact), backs up the original
|
|
103
109
|
file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
|
|
104
110
|
any other event — safe to run repeatedly.
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
|
|
6
6
|
|
|
7
|
-
> **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.
|
|
7
|
+
> **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.6),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
10
|
[](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
|
|
@@ -83,18 +83,22 @@
|
|
|
83
83
|
与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
|
|
84
84
|
`历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)`,
|
|
85
85
|
历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
|
|
86
|
-
append-only
|
|
86
|
+
append-only 的,不能在内存中改写)。
|
|
87
|
+
|
|
88
|
+
修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
|
|
87
89
|
|
|
88
90
|
```sh
|
|
89
91
|
# 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
|
|
90
|
-
# 2.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
node scripts/repair-markers.mjs --dir <sessions 根目录> # 指定数据目录
|
|
92
|
+
# 2. 运行离线修复(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
|
|
93
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
94
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
|
|
94
95
|
# 3. 重启 dsh web,损坏的会话即可正常加载历史
|
|
95
96
|
```
|
|
96
97
|
|
|
97
|
-
|
|
98
|
+
也可以全局安装一次(`npm i -g dsh-rewind-plugin`)后直接运行 `dsh-rewind-repair`;
|
|
99
|
+
源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
|
|
100
|
+
|
|
101
|
+
工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
|
|
98
102
|
改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
|
|
99
103
|
|
|
100
104
|
## 📦 安装
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "lib/index.js",
|
|
25
25
|
"types": "lib/types/index.d.ts",
|
|
26
|
+
"bin": {
|
|
27
|
+
"dsh-rewind-repair": "scripts/repair-markers.mjs"
|
|
28
|
+
},
|
|
26
29
|
"exports": {
|
|
27
30
|
".": {
|
|
28
31
|
"types": "./lib/types/index.d.ts",
|
|
@@ -41,7 +44,8 @@
|
|
|
41
44
|
"README.zh.md",
|
|
42
45
|
"docs",
|
|
43
46
|
"assets",
|
|
44
|
-
"LICENSE"
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"scripts/repair-markers.mjs"
|
|
45
49
|
],
|
|
46
50
|
"dsh": {
|
|
47
51
|
"bundle": {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline repair for sessions corrupted by the ≤ 0.2.4 marker turn-number bug.
|
|
4
|
+
*
|
|
5
|
+
* Symptom: reopening a session shows
|
|
6
|
+
* Failed to load history: conversation Context …:turn-tail… received an
|
|
7
|
+
* update before its start Match (internal)
|
|
8
|
+
* and the history disappears. Root cause: the rewind marker was numbered
|
|
9
|
+
* `lastTurn + 1`, which is exactly how the harness numbers its NEXT real turn —
|
|
10
|
+
* so the log holds an `assistant/message` (the marker) BEFORE the `turn/start`
|
|
11
|
+
* of the same turn, and the client conversation-context builder rejects that
|
|
12
|
+
* ordering.
|
|
13
|
+
*
|
|
14
|
+
* This script rewrites ONLY the `data.turn` of `dsh-rewind` empty-marker events
|
|
15
|
+
* to the last turn/start that precedes them (a turn the harness has already
|
|
16
|
+
* consumed and can never reuse). It preserves:
|
|
17
|
+
* - every other event byte-for-byte (seqs, times, surface metadata, …),
|
|
18
|
+
* - the JSONL record order and line structure,
|
|
19
|
+
* - the zstd multi-frame structure the persistence backend expects
|
|
20
|
+
* (each frame is decoded, fixed, and re-compressed independently, with the
|
|
21
|
+
* "last started turn" state threaded across frames).
|
|
22
|
+
*
|
|
23
|
+
* The original file is backed up to `session.jsonl.zstd.bak-<timestamp>` before
|
|
24
|
+
* the first write. Run with the host fully stopped (a resident session would
|
|
25
|
+
* overwrite the repaired file at its next checkpoint), then restart dsh web.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* node scripts/repair-markers.mjs # scan ~/.dsh/sessions
|
|
29
|
+
* node scripts/repair-markers.mjs --dry-run # report only, no writes
|
|
30
|
+
* node scripts/repair-markers.mjs --dir <sessions root>
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readdirSync, readFileSync, writeFileSync, copyFileSync, statSync, existsSync } from 'node:fs'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import { homedir } from 'node:os'
|
|
36
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
37
|
+
|
|
38
|
+
const ZSTD_MAGIC = 0xfd2fb528
|
|
39
|
+
const MARKER_SOURCE = { provider: 'dsh-rewind', model: 'rewind-marker' }
|
|
40
|
+
const DEFAULT_ROOT = join(homedir(), '.dsh', 'sessions')
|
|
41
|
+
|
|
42
|
+
/** @param {Buffer} buffer @returns {{start:number,end:number}[]} */
|
|
43
|
+
function scanFrames(buffer) {
|
|
44
|
+
const frames = []
|
|
45
|
+
let offset = 0
|
|
46
|
+
while (offset < buffer.length) {
|
|
47
|
+
const start = offset
|
|
48
|
+
if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break
|
|
49
|
+
offset += 4
|
|
50
|
+
const next = buffer.indexOf(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), offset)
|
|
51
|
+
frames.push(next === -1 ? { start, end: buffer.length } : { start, end: next })
|
|
52
|
+
offset = next === -1 ? buffer.length : next
|
|
53
|
+
}
|
|
54
|
+
return frames
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rewrite marker turn numbers in ONE frame's JSONL text. Line-preserving: the
|
|
59
|
+
* fix never adds or removes lines, only rewrites a marker line's turn. State
|
|
60
|
+
* (`lastStarted`) is threaded across frames, because a marker in a later frame
|
|
61
|
+
* must know the turn/start events of earlier frames.
|
|
62
|
+
* @param {string} text - this frame's JSONL text.
|
|
63
|
+
* @param {{lastStarted:number}} state - running "last started turn" state.
|
|
64
|
+
* @returns {{text:string, changed:number}} the fixed text (identical when
|
|
65
|
+
* nothing changed) and the number of rewritten markers in this frame.
|
|
66
|
+
*/
|
|
67
|
+
function fixMarkerTurns(text, state) {
|
|
68
|
+
const lines = text.split('\n')
|
|
69
|
+
let changed = 0
|
|
70
|
+
const out = []
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
if (line.trim() === '') {
|
|
73
|
+
out.push(line)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
let record
|
|
77
|
+
try {
|
|
78
|
+
record = JSON.parse(line)
|
|
79
|
+
} catch {
|
|
80
|
+
out.push(line) // keep unknown/torn lines verbatim
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (record && record.type === 'turn/start' && Number.isSafeInteger(record.data?.turn)) {
|
|
84
|
+
if (record.data.turn > state.lastStarted) state.lastStarted = record.data.turn
|
|
85
|
+
out.push(line)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
const isMarker =
|
|
89
|
+
record?.type === 'assistant/message' &&
|
|
90
|
+
record.data?.message?.source?.provider === MARKER_SOURCE.provider &&
|
|
91
|
+
record.data?.message?.source?.model === MARKER_SOURCE.model
|
|
92
|
+
if (!isMarker) {
|
|
93
|
+
out.push(line)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const badTurn = record.data.turn
|
|
97
|
+
if (badTurn === state.lastStarted) {
|
|
98
|
+
out.push(line) // already safe (post-0.2.5 marker) — idempotent
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
changed += 1
|
|
102
|
+
out.push(JSON.stringify({ ...record, data: { ...record.data, turn: state.lastStarted } }))
|
|
103
|
+
}
|
|
104
|
+
return { text: out.join('\n'), changed }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Repair one session artifact file. @returns {string} a human summary line. */
|
|
108
|
+
function repairFile(file, dryRun) {
|
|
109
|
+
const original = readFileSync(file)
|
|
110
|
+
const frames = scanFrames(original)
|
|
111
|
+
const state = { lastStarted: 0 }
|
|
112
|
+
const fixedTexts = []
|
|
113
|
+
let changed = 0
|
|
114
|
+
for (const frame of frames) {
|
|
115
|
+
const frameText = zstdDecompressSync(original.subarray(frame.start, frame.end)).toString('utf8')
|
|
116
|
+
const result = fixMarkerTurns(frameText, state)
|
|
117
|
+
changed += result.changed
|
|
118
|
+
fixedTexts.push(result.text)
|
|
119
|
+
}
|
|
120
|
+
if (changed === 0) return ` ok (no marker fix needed): ${file}`
|
|
121
|
+
if (dryRun) return ` would fix ${changed} marker(s): ${file}`
|
|
122
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
123
|
+
copyFileSync(file, `${file}.bak-${stamp}`)
|
|
124
|
+
// Preserve the zstd frame structure: one re-compressed frame per original
|
|
125
|
+
// frame, in order.
|
|
126
|
+
writeFileSync(file, Buffer.concat(fixedTexts.map(text => zstdCompressSync(Buffer.from(text)))))
|
|
127
|
+
return ` fixed ${changed} marker(s): ${file} (backup: ${file}.bak-${stamp})`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectSessions(root) {
|
|
131
|
+
const files = []
|
|
132
|
+
for (const project of readdirSync(root)) {
|
|
133
|
+
const projectDir = join(root, project)
|
|
134
|
+
let stat
|
|
135
|
+
try {
|
|
136
|
+
stat = statSync(projectDir)
|
|
137
|
+
} catch {
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (!stat.isDirectory()) continue
|
|
141
|
+
for (const entry of readdirSync(projectDir)) {
|
|
142
|
+
const file = join(projectDir, entry, 'session.jsonl.zstd')
|
|
143
|
+
if (existsSync(file)) files.push(file)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return files
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const args = process.argv.slice(2)
|
|
150
|
+
const dryRun = args.includes('--dry-run')
|
|
151
|
+
const dirIndex = args.indexOf('--dir')
|
|
152
|
+
const root = dirIndex !== -1 && args[dirIndex + 1] !== undefined ? args[dirIndex + 1] : DEFAULT_ROOT
|
|
153
|
+
|
|
154
|
+
if (!existsSync(root)) {
|
|
155
|
+
console.error(`sessions root not found: ${root}`)
|
|
156
|
+
process.exit(1)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log(`${dryRun ? '[dry-run] ' : ''}scanning ${root}`)
|
|
160
|
+
const files = collectSessions(root)
|
|
161
|
+
console.log(`${files.length} session artifact(s) found`)
|
|
162
|
+
let fixedCount = 0
|
|
163
|
+
for (const file of files) {
|
|
164
|
+
try {
|
|
165
|
+
const line = repairFile(file, dryRun)
|
|
166
|
+
if (!line.includes('no marker fix needed')) {
|
|
167
|
+
console.log(line)
|
|
168
|
+
fixedCount += 1
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(` ERROR ${file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
console.log(fixedCount === 0
|
|
175
|
+
? 'no corrupted sessions found — nothing to do'
|
|
176
|
+
: `${dryRun ? 'would repair' : 'repaired'} ${fixedCount} session(s). ${dryRun ? '' : 'Restart dsh web to reload the repaired histories.'}`)
|