dsh-rewind-plugin 0.4.1 → 0.4.2

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.
@@ -20,6 +20,26 @@
20
20
  * kept), and restores read/write the real file system with plain `node:fs`
21
21
  * — independent of the fs service.
22
22
  *
23
+ * Crash safety (this module's own engineering asset):
24
+ * - Checkpoint commits are ATOMIC: the entry JSON is written to a sibling
25
+ * temp file and renamed over the target, so a host crash mid-write can
26
+ * never leave a readable half-written entry — at worst an inert `.tmp`
27
+ * leftover that the next commit of the same file overwrites and that no
28
+ * reader ever picks up.
29
+ * - Every restore pass is JOURNALED. Before mutating anything the store
30
+ * captures the pre-restore ("rescue") state of each planned path and
31
+ * persists an intent journal (`restore-journal-<op>.json` in the session
32
+ * dir), then marks each action done as it is applied. A crash at any point
33
+ * leaves the journal on disk; after a host restart
34
+ * `reconcileRestores(sessionId)` re-derives from the REAL disk which
35
+ * paths already match the target and which are still pending (reporting
36
+ * "restored up to where, what changed"), auto-heals journals whose goal is
37
+ * already reached, and `continueRestore` / `rollbackRestore` finish the
38
+ * interrupted op or undo it back to the exact pre-restore state.
39
+ * - Journal IO is best-effort and never fails a restore: if the journal
40
+ * cannot be written the restore proceeds exactly like the pre-journal code
41
+ * (crash safety degrades, behavior does not).
42
+ *
23
43
  * Restore semantics (identical to Claude Code): for every path with entries
24
44
  * anchored at or after the target message, apply the EARLIEST entry — write
25
45
  * the before content back, or delete the file when that entry recorded a
@@ -65,6 +85,92 @@ export interface RestoreOutcome {
65
85
  }
66
86
  /** Deletes one file by its real path (node:fs, bypassing the fs service). */
67
87
  export type DeleteFile = (path: string) => Promise<void>;
88
+ /**
89
+ * Test-only fault injection: a crash point inside the write/restore paths.
90
+ * The hook THROWS to simulate a host crash at the exact point; the throw
91
+ * propagates out of the store method, leaving the journal on disk in its
92
+ * current state. Production callers never pass it (undefined = no-op).
93
+ */
94
+ export type CrashPoint = 'before-action' | 'after-action' | 'after-temp-write';
95
+ /** Options for the journaled restore paths; `crash` is the test-only seam. */
96
+ export interface RestoreRunOptions {
97
+ /**
98
+ * Throws at the given point to simulate a host crash: `before-action`
99
+ * (before an action's fs op), `after-action` (right after the fs op,
100
+ * before its done-mark is persisted), `after-temp-write` (inside an atomic
101
+ * commit, between the temp write and the rename). `index` is the action
102
+ * index for the restore loops.
103
+ */
104
+ readonly crash?: (point: CrashPoint, index?: number) => void;
105
+ }
106
+ /**
107
+ * Lifecycle of one restore operation journal. Terminal states are kept on
108
+ * disk as a tiny audit trail and skipped by reconciliation.
109
+ */
110
+ export type RestoreJournalState = 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required';
111
+ /**
112
+ * One journaled restore action — a mutable working record that the restore
113
+ * loop updates (done/failed) as it applies the pass.
114
+ */
115
+ export interface RestoreJournalAction {
116
+ readonly path: string;
117
+ readonly action: 'restore' | 'delete';
118
+ /** Target content for a restore; null for a delete. */
119
+ readonly before: string | null;
120
+ /**
121
+ * Pre-restore disk state ("rescue"): the content the file had right before
122
+ * the restore started, or null when it was absent. Rollback writes this
123
+ * back, so the pre-restore state is recoverable exactly.
124
+ */
125
+ readonly rescue: string | null;
126
+ /** Set when the rescue capture failed: rollback then skips this path. */
127
+ rescueError?: string;
128
+ /** True once the action's fs op completed and was marked. */
129
+ done: boolean;
130
+ /** Per-action failure message; the restore pass never aborts. */
131
+ failed?: string;
132
+ }
133
+ /** Durable journal for one attempted restore (written atomically). */
134
+ export interface RestoreJournal {
135
+ readonly version: 1;
136
+ readonly id: string;
137
+ readonly sessionId: string;
138
+ readonly targetSeq: number;
139
+ readonly startedAt: number;
140
+ finishedAt?: number;
141
+ state: RestoreJournalState;
142
+ readonly actions: RestoreJournalAction[];
143
+ /** Set when a rollback pass failed partway (state becomes `recovery-required`). */
144
+ rollbackError?: string;
145
+ }
146
+ /**
147
+ * Result of reconciling one interrupted restore journal against the real
148
+ * disk. Path status is relative to the op's current goal: the restore target
149
+ * for `running` journals, the pre-restore (rescue) state for
150
+ * `rollback-running` / `recovery-required` journals — disambiguate with
151
+ * {@link RestoreReconcileReport.journalState}.
152
+ */
153
+ export interface RestoreReconcileReport {
154
+ readonly opId: string;
155
+ /** `interrupted` = a crash left the op unfinished; `recovery-required` = a rollback could not complete. */
156
+ readonly state: 'interrupted' | 'recovery-required';
157
+ /** Raw journal state (`running` | `rollback-running` | `recovery-required`). */
158
+ readonly journalState: RestoreJournalState;
159
+ readonly targetSeq: number;
160
+ readonly startedAt: number;
161
+ /** Paths whose disk already matches the op's goal. */
162
+ readonly restored: readonly string[];
163
+ /** Paths still short of the op's goal (not yet applied / not yet rolled back). */
164
+ readonly pending: readonly string[];
165
+ /** Actions that failed during the pass (kept failed until a redo succeeds). */
166
+ readonly failed: readonly {
167
+ path: string;
168
+ message: string;
169
+ }[];
170
+ readonly rollbackError?: string;
171
+ /** Set when the journal file itself is corrupt: it cannot be reconciled. */
172
+ readonly corrupt?: string;
173
+ }
68
174
  /**
69
175
  * Current-on-disk state probe used by restore planning. Injected so the plan
70
176
  * logic runs against a fake FS in tests; the production default reads the
@@ -102,13 +208,28 @@ export declare class SnapshotStore {
102
208
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
103
209
  private static readonly PRUNE_INTERVAL_MS;
104
210
  private lastPruneAt;
211
+ /**
212
+ * Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
213
+ * commits in the same millisecond would TIE on the entry `time` field and
214
+ * entriesAfter's (anchorSeq, time) sort would fall back to the readdir
215
+ * order — filesystem-dependent, so a re-read could pick the WRONG "earliest"
216
+ * version for a path. Bumping past the previous commit keeps the capture
217
+ * order reproducible after a re-read. The read-modify-write below is
218
+ * synchronous (before the first await), so concurrent commits can never
219
+ * observe the same value. Across restarts wall-clock monotonicity holds
220
+ * (restart gaps dwarf 1ms); a backwards NTP step is the only way to break
221
+ * it, and even then the in-process order still holds.
222
+ */
223
+ private lastEntryTime;
105
224
  constructor(root?: string);
106
225
  /** Absolute path of one session's snapshot directory (id sanitized). */
107
226
  sessionDir(sessionId: string): string;
108
227
  /** Absolute path of one anchor group directory. */
109
228
  anchorDir(sessionId: string, anchorSeq: number): string;
110
- /** Commit one before-backup under its turn's anchor group. */
111
- recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>): Promise<void>;
229
+ /** Commit one before-backup under its turn's anchor group (atomic write). */
230
+ recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>, opts?: {
231
+ readonly crash?: (point: CrashPoint) => void;
232
+ }): Promise<void>;
112
233
  /**
113
234
  * All committed entries anchored at or after `targetSeq`, newest first (for
114
235
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -163,13 +284,117 @@ export declare class SnapshotStore {
163
284
  * the backup; a delete whose file is ALREADY absent is a silent no-op (not
164
285
  * a failure — the target state is already reached). Failures are per-file
165
286
  * and never abort the pass.
287
+ *
288
+ * The pass is journaled for crash safety: the pre-restore ("rescue") state
289
+ * of every planned path is captured and an intent journal persisted BEFORE
290
+ * any mutation, then each action is marked done as it is applied. A host
291
+ * crash at any point leaves the journal on disk; after a restart
292
+ * {@link reconcileRestores} reports where the restore stopped,
293
+ * {@link continueRestore} finishes it and {@link rollbackRestore} undoes it
294
+ * back to the exact pre-restore state. Journal IO itself never fails the
295
+ * restore (it degrades to a journal-less pass).
296
+ */
297
+ restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe, opts?: RestoreRunOptions): Promise<RestoreOutcome>;
298
+ /** Prefix of one restore-op journal file inside the session dir. */
299
+ private static readonly JOURNAL_PREFIX;
300
+ /** Absolute path of one restore-op journal file. */
301
+ private journalPath;
302
+ /**
303
+ * Best-effort journal persist: journal IO failures are non-fatal by design —
304
+ * a restore must never fail because its audit journal could not be written.
305
+ * reconcileRestores() re-derives the true state from the disk, so a missing
306
+ * or stale journal only loses the trail, never the recovery ability.
307
+ */
308
+ private saveJournal;
309
+ /**
310
+ * Journal one restore pass before mutating anything: capture the rescue
311
+ * (pre-restore) state of every planned path and persist the intent
312
+ * atomically. Returns the in-memory journal; a persist failure degrades to
313
+ * a journal-less restore (non-fatal, see {@link saveJournal}).
314
+ */
315
+ private beginRestore;
316
+ /**
317
+ * Read one journal by op id; undefined when it does not exist. A corrupt
318
+ * journal THROWS (fail-loud): unlike checkpoint entries, silently dropping
319
+ * a journal would silently erase the interrupted restore's recovery record.
320
+ */
321
+ private readJournal;
322
+ /**
323
+ * Every journal file of a session — valid ones plus corrupt ones with their
324
+ * error — so reconciliation can report corruption instead of dropping it.
325
+ */
326
+ private listJournals;
327
+ /**
328
+ * Execute ONE fs mutation with exactly the pre-journal semantics: a delete
329
+ * runs through the injected deleteFile (ENOENT tolerated — the file is
330
+ * already absent, i.e. the target state is reached), a restore is a plain
331
+ * writeFile with a recursive mkdir of the parent. Returns how the outcome
332
+ * should record it.
166
333
  */
167
- restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe): Promise<RestoreOutcome>;
334
+ private applyActionToDisk;
335
+ /**
336
+ * Reconcile the session's restore journals against the real disk — the
337
+ * "host restart" account: for every interrupted op, report which paths
338
+ * already match its goal (restored) and which are still pending, and expose
339
+ * any recorded failures. Journals whose goal is already fully reached on
340
+ * disk (e.g. a later rewind completed the work) are auto-healed to their
341
+ * terminal state and not reported. A corrupt journal is reported
342
+ * `recovery-required` — never silently dropped.
343
+ *
344
+ * @param sessionId - session whose journals to reconcile.
345
+ * @param probe - current-disk state probe (defaults to the real FS).
346
+ * @returns one report per non-terminal journal still needing attention.
347
+ */
348
+ reconcileRestores(sessionId: string, probe?: DiskProbe): Promise<RestoreReconcileReport[]>;
349
+ /**
350
+ * Reconcile ONE non-terminal journal against the real disk. Returns
351
+ * undefined when the op's goal is already fully reached (auto-heals to the
352
+ * terminal state); otherwise a report of restored/pending/failed paths.
353
+ * For `running` journals the goal is the restore target; for
354
+ * `rollback-running` / `recovery-required` journals it is the rescue
355
+ * (pre-restore) state.
356
+ */
357
+ private reconcileJournal;
358
+ /**
359
+ * 补做 (redo) an interrupted restore: finish the op by applying every action
360
+ * whose disk state does not yet match its goal — the restore target for
361
+ * `running` journals. Actions are decided by the REAL disk (the same "disk
362
+ * is truth" rule as reconciliation), so a crash between an fs op and its
363
+ * done-mark is completed deterministically and a path the user already
364
+ * fixed is marked done without being rewritten. Failed actions are retried;
365
+ * a re-failure re-records the failure. The journal becomes `completed` once
366
+ * every action reaches the target.
367
+ */
368
+ continueRestore(sessionId: string, opId: string, deleteFile: DeleteFile, probe?: DiskProbe, opts?: RestoreRunOptions): Promise<RestoreOutcome>;
369
+ /**
370
+ * 回滚 (roll back) an interrupted restore: undo every action whose disk
371
+ * state does not match its rescue (pre-restore) record, returning the
372
+ * workspace to the exact state it had before the restore started. Decided
373
+ * by the REAL disk, so actions the crash left applied-but-unmarked are
374
+ * undone too, and a path already back at its rescue state is skipped —
375
+ * the pass is idempotent across crashes (a retry finishes the remaining
376
+ * actions). The journal moves `running` → `rollback-running` → `rolled-back`;
377
+ * a failed undo leaves it `recovery-required` (retryable), and paths whose
378
+ * rescue capture failed are reported and left untouched.
379
+ */
380
+ rollbackRestore(sessionId: string, opId: string, deleteFile: DeleteFile, probe?: DiskProbe, opts?: RestoreRunOptions): Promise<RestoreOutcome>;
168
381
  /**
169
382
  * Drop the session's oldest anchor groups beyond `keep` (default
170
- * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
383
+ * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories. Also
384
+ * recycles terminal restore journals (see {@link pruneTerminalJournals}),
385
+ * so the per-commit cap bounds BOTH the checkpoint entries and the journal
386
+ * accumulation.
171
387
  */
172
388
  prune(sessionId: string, keep?: number): Promise<void>;
389
+ /**
390
+ * Recycle terminal restore journals (`completed` / `rolled-back`): once an
391
+ * op finished, its journal's before + rescue content is dead weight that
392
+ * would otherwise accumulate without bound (one journal per both-mode
393
+ * rewind). Non-terminal journals (crashed ops awaiting reconcile /
394
+ * continue / rollback) and unclassifiable (corrupt) ones are ALWAYS kept —
395
+ * a recovery record that cannot be classified is never destroyed.
396
+ */
397
+ private pruneTerminalJournals;
173
398
  /** True when a path exists on disk (used by tests and diagnostics). */
174
399
  exists(path: string): Promise<boolean>;
175
400
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.4.1",
4
- "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
3
+ "version": "0.4.2",
4
+ "description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",
@@ -38,7 +38,7 @@
38
38
  "lib",
39
39
  "cordis.patch.yml",
40
40
  "README.md",
41
- "README.zh.md",
41
+ "README.en.md",
42
42
  "docs",
43
43
  "assets",
44
44
  "LICENSE"
package/README.zh.md DELETED
@@ -1,159 +0,0 @@
1
- # dsh-rewind
2
-
3
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
4
-
5
- [![npm version](https://img.shields.io/npm/v/dsh-rewind-plugin.svg)](https://www.npmjs.com/package/dsh-rewind-plugin)
6
- [![npm license](https://img.shields.io/npm/l/dsh-rewind-plugin.svg)](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
7
-
8
- > [English](README.md) | 中文
9
-
10
- 刻意保持聚焦,只做一件事:**就地回退到任意更早的用户消息**。
11
-
12
- | 模式 | 对话 | 工作区文件 |
13
- | --- | --- | --- |
14
- | **仅回退对话** | 剪回目标消息之前 | 不动 |
15
- | **回退对话和代码** | 剪回目标消息之前 | 还原到目标之前的 state(改过的文件写回、之后新建的文件删除) |
16
-
17
- 回退即**时间回溯**:目标消息及其之后全部内容(agent 回复、工具调用)从模型上下文**与**渲染对话中撤回——不新建会话、不切换窗口——目标消息的文本会回填输入框,可修改后重发。
18
-
19
- 插件从不改写 append-only 会话日志,从不触碰你的 git 仓库。
20
-
21
- ## 效果预览
22
-
23
- 每条用户消息的操作行上多出一个 **↶ 回退** 按钮。点击后弹出模式选择浮层;「回退对话和代码」会先展示待还原/删除的文件清单再确认(目标之后无跟踪变更时不显示该选项,对齐 Claude Code 的 code-restore 可见性)。
24
-
25
- <table>
26
- <tr>
27
- <td align="center"><img src="assets/screenshots/rewind-button.png" width="440" alt="用户消息旁的 ↶ 回退按钮"><br><sub>用户消息旁的 ↶ 回退按钮</sub></td>
28
- <td align="center"><img src="assets/screenshots/mode-popover.png" width="440" alt="模式选择浮层"><br><sub>模式选择浮层</sub></td>
29
- </tr>
30
- <tr>
31
- <td align="center"><img src="assets/screenshots/impact-list.png" width="440" alt="影响清单"><br><sub>「回退对话和代码」影响清单</sub></td>
32
- <td align="center"><img src="assets/screenshots/rewind-candidates.png" width="440" alt="/rewind 候选面板"><br><sub>/rewind 候选面板</sub></td>
33
- </tr>
34
- </table>
35
-
36
- ## 安装
37
-
38
- ```sh
39
- dsh plugin --profile web add dsh-rewind-plugin
40
- ```
41
-
42
- 装完重启 `dsh web`(`--profile web`)。
43
-
44
- > ⚠️ npm 上的 `dsh-rewind` 属于其他作者,请用 `dsh-rewind-plugin` 安装。
45
-
46
- 给贡献者:可从本地 checkout 或 pin 的 commit 安装——`dsh plugin --profile web add /path/to/dsh-rewind` 或 `dsh plugin --profile web add github:SiriLee/dsh-rewind#<sha>`。git 安装首次会失败:pnpm 默认禁止 git 依赖执行构建脚本,需先在 profile 的 `pnpm-workspace.yaml` 加 `allowBuilds`;之后 pnpm 会执行插件的 `prepare`(完整构建)并装入 profile。
47
-
48
- ## 使用
49
-
50
- 1. **hover** 任意你发送过的用户消息——操作行出现 **↶ 回退** 按钮。
51
- 2. **点击它。** 目标即这条消息;小浮层提供两种模式(目标之后没有跟踪的变更时,「回退对话和代码」不显示)。
52
- 3. 回退以一条会话内命令执行;结果消息确认,被撤回消息的文本自动填入输入框,可编辑后重发。
53
-
54
- **命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
55
-
56
- 候选面板与模式弹层均支持键盘操作:↑↓ 移动、Enter 确认、Esc 取消/返回。
57
-
58
- 回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
59
-
60
- ## 原理
61
-
62
- ### 1. 对话回退(同窗口就地)
63
-
64
- 插件向会话日志追加一条**空内容标记** `assistant/message`,其 `surfaceOp: { op: 'replace', start, end }` 把目标消息之后的全部 surface 节点替换为标记本身:
65
-
66
- - 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
67
- - 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
68
- - 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
69
- - 标记自带**幽灵步骤框架**——自己的 `step/start` … `step/end`,step 号取该回合未用过的新号(`markerStepOf`):harness 的 token-meter 重放要求每条 `assistant/message` 位于打开的 step 内,裸标记则会让该会话的 `/compact` 失效。
70
- - append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
71
-
72
- 若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
73
-
74
- ### 2. Checkpoint 文件还原
75
-
76
- 插件跟踪写类工具:`write`、`edit`、`str_replace_editor`(变更子命令 `create` / `str_replace` / `insert`):
77
-
78
- 1. **写前备份**(`tools/execute`,around-dispatch 阶段):读取目标文件,把解析后的路径与内容放入 pending 表。此阶段只在任何 pre-execute 审批门放行之后运行——审批 `ask` 短路(dsh-edit-approval)**无法跳过**备份,被拒绝的调用也不会记录。若读取失败(如权限错误),该次变更直接不入备份——插件只在日志中警告,**不会阻塞写操作**。
79
- 2. **落盘提交**(`tools/post-execute`):备份按当前轮**锚点消息 seq** 写入 `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/<callId>.json`。
80
- 3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的备份与磁盘**对账后生效**(改过的写回最早 before、新建的删除、一致的跳过——幂等)。符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
81
- 4. 工具体**抛异常**会跳过 `tools/post-execute`;`tools/result` 兜底清掉 pending,避免内存泄漏。
82
-
83
- 备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
84
-
85
- ## 明确不做的事
86
-
87
- - **整树 / git-first 快照**——只备份写类工具编辑及已追踪文件的外部改动;从未被工具碰过的文件不还原:与 Claude Code 相同,此类回退交由用户 git 处理。
88
- - **子代理(subagent)的编辑**——不跟踪(同 Claude Code):子代理运行在自己的会话里,其备份无法被父会话的回退还原。
89
- - **fork / 分支回退与 `/compact`**——harness 已内置(「在新对话中分支」、compact)。
90
-
91
- ## 与同类项目对比
92
-
93
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 还有 [Anionex/dsh-turn-rewind](https://github.com/Anionex/dsh-turn-rewind)——同样是回退插件,用户面想法相同(每条消息下挂一个动作,回退对话并还原工作区文件),但路线不同:
94
-
95
- | 维度 | dsh-rewind(本项目) | Anionex dsh-turn-rewind |
96
- | --- | --- | --- |
97
- | 对话回退 | **同窗口就地**——把模型可见 surface 剪回目标;append-only 日志原封不动 | 在上一 `turn/end` 处**派生新 Session**;原会话永远保留 |
98
- | 文件还原引擎 | **轻量写前备份**,只跟踪写类工具,纯 `node:fs` 还原 | **Change Ledger**——持久化还原点引擎,带 Git 围栏、审批门、救援点与崩溃对账 |
99
- | 跟踪范围 | 仅写类工具编辑(同 Claude Code) | 任意 Git 管理文件(要求 Git worktree) |
100
- | 公共服务 API | 无——聚焦单用途插件 | 有——`ctx.changeLedger` 服务 + `/turn-rewind` HTTP 端点 |
101
-
102
- 本质区别:dsh-turn-rewind 因保持日志不可变而必须派生新会话;本插件用空标记**就地剪掉**模型可见 surface,于是原对话在同一个窗口继续——这段并不平凡的实现正是 dsh-turn-rewind 绕开的部分。
103
-
104
- ## 兼容性
105
-
106
- - Node.js `^22.19.0 || >=24.0.0`。
107
- - DeepSeek Harness web 配置档(`dsh --profile web`);peer `@deepseek-ai/*` 包由 harness 运行时解析。
108
-
109
- > [!WARNING]
110
- > 本项目与 DeepSeek Harness 均处于开发者预览阶段。可复现环境请 pin 精确版本,
111
- > 并阅读上述行为说明。
112
-
113
- ## 客户端契约
114
-
115
- 需要获知哪些转录行被回退撤回的第三方 DOM 插件,应使用
116
- `dsh-rewind-plugin/client` 导出的稳定、与本地化无关的纯函数
117
- (`hiddenSeqsOf`、`targetSeqOfArgs`),切勿解析 `outcome.text`。
118
- `data-dsh-rewind-hidden` 属性标记被撤回的行(仅观测性)。
119
- 详见:[docs/client-contract.zh.md](docs/client-contract.zh.md)。
120
-
121
- ## 已知问题
122
-
123
- `v0.2.4` 及之前版本创建的回退在随后继续对话时可能损坏客户端重放(标记 turn 与下一个 `turn/start` 撞号)。只影响升级前就已存在的旧会话。离线修复工具(`dsh-rewind-repair`)在 v0.4.0 之前随包提供,此后不再提供——需要修复的用户可安装 v0.4.0 之前的版本([完整步骤](docs/troubleshooting.zh.md))。
124
-
125
- `v0.3.3` 及之前版本的回退以裸标记追加(无步骤框架),token-meter 重放会拒绝该日志,`/compact` 对该会话失效。新版本均已兼容;已受影响的旧会话暂不提供在线修复——请新建会话。
126
-
127
- ## 安全
128
-
129
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
130
-
131
- > **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
132
-
133
- ## 开发
134
-
135
- ```sh
136
- npm install # devDeps 来自 npm registry
137
- npm run typecheck # tsc 三面编译(host + client + client-test)
138
- npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
139
- npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
140
- node scripts/verify-host.mjs # 端到端验证构建产物
141
- ```
142
-
143
- `prepare` 执行完整构建,所以 git 安装与 `npm pack` / `npm publish` 总会产出完整的 `lib/` 与 `LICENSE`。
144
-
145
- 维护者:模块地图与 harness 接口参考见 [docs/harness-reference.md](docs/harness-reference.md);发布步骤见 [docs/release.md](docs/release.md)。
146
-
147
- ## 发布
148
-
149
- 通过 GitHub Actions Trusted Publishing(OIDC,无存储 `NPM_TOKEN`)发布:推送 `v<版本>` tag,CI 即带 Sigstore provenance 发布。
150
-
151
- ```sh
152
- npm version patch && git push origin main --tags
153
- ```
154
-
155
- 一次性 npm 侧配置与完整流程:见 [docs/release.md](docs/release.md)。
156
-
157
- ## 许可
158
-
159
- [MIT](LICENSE)