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 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.5) 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.
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
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](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 script (scans every session under ~/.dsh/sessions,
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
- node scripts/repair-markers.mjs # default: scan ~/.dsh/sessions
96
- node scripts/repair-markers.mjs --dry-run # report only, no writes
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
- The script only rewrites the `data.turn` of `dsh-rewind` empty-marker events
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.5),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
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
  [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
10
10
  [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](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. 运行离线修复脚本(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
91
- node scripts/repair-markers.mjs # 默认扫描 ~/.dsh/sessions
92
- node scripts/repair-markers.mjs --dry-run # 只报告不写盘
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
- 脚本只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / 帧结构不变),
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.5",
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.'}`)