dsh-rewind-plugin 0.4.2 → 0.5.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/CONTRIBUTING.md +72 -0
- package/README.en.md +37 -39
- package/README.md +43 -39
- package/SECURITY.md +154 -0
- package/docs/README.md +29 -0
- package/docs/architecture.md +128 -0
- package/docs/compat/tracking-boundary.md +103 -0
- package/docs/compat/tracking-boundary.zh.md +55 -0
- package/docs/compat/troubleshooting.md +41 -0
- package/docs/{troubleshooting.zh.md → compat/troubleshooting.zh.md} +15 -0
- package/docs/format.md +150 -0
- package/docs/harness-reference.md +7 -3
- package/docs/release/release.md +69 -0
- package/docs/{release.md → release/release.zh.md} +6 -52
- package/docs/snapshot-auto-cleanup.md +71 -0
- package/docs/snapshot-auto-cleanup.zh.md +42 -0
- package/lib/index.js +520 -37
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/index.d.ts +3 -1
- package/lib/types/locales.d.ts +17 -0
- package/lib/types/snapshot-cleanup.d.ts +125 -0
- package/lib/types/snapshot.d.ts +146 -11
- package/package.json +4 -1
- package/docs/troubleshooting.md +0 -44
- /package/docs/{compat-audit.md → compat/audit.md} +0 -0
- /package/docs/{client-contract.md → contract/client-contract.md} +0 -0
- /package/docs/{client-contract.zh.md → contract/client-contract.zh.md} +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
How dsh-rewind is built: module layering, the rewind pipeline, the checkpoint
|
|
4
|
+
pipeline, the compatibility strategy, and the roadmap. The module map below is
|
|
5
|
+
the same one in `AGENTS.md`; this document adds the wiring between the
|
|
6
|
+
modules. The durable on-disk format is specified separately in
|
|
7
|
+
`docs/format.md`, the security model in `SECURITY.md`.
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
One thing: rewind a conversation **in place** to any earlier user message —
|
|
12
|
+
never forking a session or switching windows — with an optional Claude-Code-
|
|
13
|
+
style workspace file restore (`both` mode). No network, no git operations, no
|
|
14
|
+
credentials access.
|
|
15
|
+
|
|
16
|
+
## Module layering
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
src/
|
|
20
|
+
├── index.ts host plugin: /rewind command + checkpoint pipeline
|
|
21
|
+
├── rewind.ts pure planning: target parsing, surface-range plan,
|
|
22
|
+
│ candidate listing, marker turn/step math
|
|
23
|
+
├── snapshot.ts checkpoint store: disk before-backups, journaled restore,
|
|
24
|
+
│ reconcile / continue / rollback, bounded prune
|
|
25
|
+
├── session-cwd.ts session working-directory resolution (fs-tools rule)
|
|
26
|
+
└── client/ browser half: per-message ↶ button (portal bridge),
|
|
27
|
+
mode popover, hidden-span computation, candidate parsing,
|
|
28
|
+
locales, styles
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Two dependency rules keep the design testable:
|
|
32
|
+
|
|
33
|
+
1. **`rewind.ts` is pure** — no I/O, no `Session` dependency; everything
|
|
34
|
+
derives from the event log + ordered surface, so the whole planning layer
|
|
35
|
+
is unit-testable without a host.
|
|
36
|
+
2. **`snapshot.ts` is host-independent** — it talks to the disk through plain
|
|
37
|
+
`node:fs` plus injected seams (`DiskProbe`, `DeleteFile`, a test-only
|
|
38
|
+
`crash` hook), so the store is testable without the harness.
|
|
39
|
+
|
|
40
|
+
The client never reads the DOM to derive rewind state; it consumes the host's
|
|
41
|
+
machine channels (see [Compatibility strategy](#compatibility-strategy)).
|
|
42
|
+
|
|
43
|
+
## Rewind pipeline
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
↶ button / /rewind @<seq> both
|
|
47
|
+
→ handleRewind: parseRewindTarget + planRewind (target must be a
|
|
48
|
+
user/message currently on the surface)
|
|
49
|
+
→ agent.cancel({ keepInbox: true }) if running; waitForAgentIdle
|
|
50
|
+
→ dropPendingSteering (next-step inbox only; queued messages untouched)
|
|
51
|
+
→ append ghost step frame: step/start (turn = last started turn,
|
|
52
|
+
step = that turn's next unused number) … marker … step/end
|
|
53
|
+
→ marker = empty assistant/message with
|
|
54
|
+
surfaceOp { op: 'replace', start, end } over every surface node
|
|
55
|
+
after the target (+ sourceEventSeqs = shadowed seqs)
|
|
56
|
+
→ if mode 'both': store.restoreAfter(targetSeq) + syncRestoreObservations
|
|
57
|
+
→ result text carries machine tokens (impact=<n>, restore:/delete: lines)
|
|
58
|
+
→ client: hides withdrawn rows (data-dsh-rewind-hidden), refills composer
|
|
59
|
+
with the target message's text
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Key invariants:
|
|
63
|
+
|
|
64
|
+
- **The log is append-only.** The marker is the *only* mutation: it cuts the
|
|
65
|
+
model-visible surface, never the raw history (search/export still see it).
|
|
66
|
+
- **The marker is empty** (`content: []`, derives to `null` in the model
|
|
67
|
+
context) so it renders nothing and enters no model context.
|
|
68
|
+
- **The ghost step frame exists for the token-meter**: replay requires every
|
|
69
|
+
`assistant/message` inside an open step of the same `(turn, step)`; a bare
|
|
70
|
+
marker appended while idle would throw on the next `measure()` and silently
|
|
71
|
+
break `/compact`. The turn/step numbers are chosen so they can never collide
|
|
72
|
+
with a future real turn (`markerTurnOf` / `markerStepOf` — see
|
|
73
|
+
`src/rewind.ts`).
|
|
74
|
+
- **Restore is reconciled against the live disk** (`planRestore`), so repeated
|
|
75
|
+
rewinds are idempotent and a rewind whose target state already matches is a
|
|
76
|
+
no-op.
|
|
77
|
+
|
|
78
|
+
## Checkpoint pipeline (Claude Code before-backup model)
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
tools/execute captureBefore: for write / edit / str_replace_editor
|
|
82
|
+
(mutating commands only), read the file's BEFORE state;
|
|
83
|
+
subagent edits are NOT tracked (Claude Code alignment).
|
|
84
|
+
tools/post-execute commitEntry: anchor = latest user/message seq; skip
|
|
85
|
+
failed calls; write the before-backup to the store.
|
|
86
|
+
session/event user/message boundary: reconcileTracked re-reads every
|
|
87
|
+
(user/message) tracked file and records a new before-backup for any
|
|
88
|
+
whose disk state changed since last seen — external
|
|
89
|
+
edits/deletions enter the record this way.
|
|
90
|
+
prune keeps the newest 100 anchor groups per session, storing
|
|
91
|
+
identical before-content as in-place links that are
|
|
92
|
+
materialized before their group is dropped, and recycles
|
|
93
|
+
terminal restore journals.
|
|
94
|
+
pruneStale cross-session auto-cleanup (default off): whole
|
|
95
|
+
long-inactive session dirs past the cutoff are removed;
|
|
96
|
+
the active session is never targeted.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Compatibility strategy
|
|
100
|
+
|
|
101
|
+
- **Peer ranges as OR-union of DSH rc tuples** (`^0.1.0-rc.6 || ^0.1.1-rc.2`):
|
|
102
|
+
npm's prerelease rules require the peer range to share the host's
|
|
103
|
+
`[major, minor, patch]` tuple; `scripts/check-dsh-version.mjs` detects when
|
|
104
|
+
a new tuple requires appending. See `docs/release/release.md`.
|
|
105
|
+
- **Test-driven investigation**: `tests/compat-invariants.ts` /
|
|
106
|
+
`compat-interop` / `compat-gaps` probe harness behavior and pin findings in
|
|
107
|
+
`docs/compat/audit.md`; `scripts/verify-host.mjs` runs a real end-to-end
|
|
108
|
+
rewind + `/compact` chain (the full check suite).
|
|
109
|
+
- **Stable machine channels for third parties**: `dsh-rewind-plugin/client`
|
|
110
|
+
exports the pure hidden-span computation; withdrawn rows carry
|
|
111
|
+
`data-dsh-rewind-hidden`; both are semver-protected
|
|
112
|
+
(`docs/contract/client-contract.md`). DOM coupling is minimized to a small
|
|
113
|
+
set of marked rows plus a structurally-typed slot registration.
|
|
114
|
+
|
|
115
|
+
## Roadmap
|
|
116
|
+
|
|
117
|
+
Ideas under consideration, not commitments:
|
|
118
|
+
|
|
119
|
+
- **Multi-process identity/lock**: the current in-flight guard is
|
|
120
|
+
per-process; a cross-process exclusive lock (like the change-ledger
|
|
121
|
+
competitors) would cover multiple host processes on one worktree.
|
|
122
|
+
- **Lazy-commit UX**: an explicit confirm-then-apply step (a pending state)
|
|
123
|
+
to reduce mis-touch risk on in-window rewinds, which are inherently less
|
|
124
|
+
reversible than forked branches.
|
|
125
|
+
- **Locale expansion**: client/host copy is zh/en today; the copy layer
|
|
126
|
+
(`src/locales.ts`, `src/client/locales.ts`) is already keyed for more.
|
|
127
|
+
- **Composer re-send polish**: the target text is already refilled after a
|
|
128
|
+
rewind; a first-class "edit and re-send" affordance is a small extension.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# File-rewind tracking boundary
|
|
2
|
+
|
|
3
|
+
> Behavioral note — this is **not** a bug. The plugin's file rewind deliberately
|
|
4
|
+
> tracks only the model's dedicated editing tools, and applies an additional
|
|
5
|
+
> boundary (message-boundary re-check) to registered files. Written for issue
|
|
6
|
+
> [#5](https://github.com/SiriLee/dsh-rewind/issues/5); aligned with Claude Code's
|
|
7
|
+
> checkpoint semantics. [中文](tracking-boundary.zh.md)
|
|
8
|
+
|
|
9
|
+
## One-line summary
|
|
10
|
+
|
|
11
|
+
Rewind **always** restores files edited with the dedicated editing tools
|
|
12
|
+
(`write` / `edit` / `str_replace_editor`) because the plugin snapshots them
|
|
13
|
+
before the edit. Command-line (or hand) edits are covered **only when the file
|
|
14
|
+
was previously registered** by an editing tool in the same conversation — and,
|
|
15
|
+
in that case, the change itself is recorded, but rewinding to a boundary inside
|
|
16
|
+
an unchanged interval is only as precise as the next recorded state (see
|
|
17
|
+
[Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
|
|
18
|
+
|
|
19
|
+
## How file changes are classified
|
|
20
|
+
|
|
21
|
+
**Editing tools** (create/overwrite, edit lines, insert): each call tells the
|
|
22
|
+
plugin *which file* it will change, so the plugin snapshots the file's current
|
|
23
|
+
content **before** the edit and can restore it on rewind. This path is
|
|
24
|
+
deterministic — using them always yields a restorable rewind.
|
|
25
|
+
|
|
26
|
+
**Command-line or manual edits**: PowerShell (`pwsh`) writes on Windows, `sed -i`
|
|
27
|
+
on Linux, or saving a file by hand. These do **not** tell the plugin which file
|
|
28
|
+
is being touched, so there is nothing to snapshot beforehand.
|
|
29
|
+
|
|
30
|
+
Command/manual edits are nevertheless covered in one case: if the file was
|
|
31
|
+
**previously registered** by an editing tool in this conversation, the plugin
|
|
32
|
+
re-checks registered files at each user-message boundary and records a change it
|
|
33
|
+
sees, so those files restore on rewind too.
|
|
34
|
+
|
|
35
|
+
## When a rewind is not available
|
|
36
|
+
|
|
37
|
+
These are cases where there is **no recorded pre-change state at all** (as
|
|
38
|
+
opposed to the precision gap in the next section, where a state exists but an
|
|
39
|
+
intermediate message's exact value is lost):
|
|
40
|
+
|
|
41
|
+
1. **The model edits the file with a command on its first touch, and the file
|
|
42
|
+
was never registered.** No snapshot, no registration — nothing was recorded
|
|
43
|
+
around the change, so there is nothing to restore from. Every rewind target
|
|
44
|
+
reports no restorable change for this file.
|
|
45
|
+
2. **The file was manually edited before the model's first editing-tool change.**
|
|
46
|
+
The plugin's first snapshot is taken at the editing-tool call, so rewind can
|
|
47
|
+
return the file only to its state *after* the manual edit, not before it.
|
|
48
|
+
|
|
49
|
+
Both stem from a deliberate **lightweight** trade-off. Supporting command edits
|
|
50
|
+
in every case would require scanning the whole workspace and classifying what
|
|
51
|
+
arbitrary commands changed — costly, error-prone, hard to maintain. Instead the
|
|
52
|
+
plugin registers only the editing tools that hand it an exact path, and relies
|
|
53
|
+
on the message-boundary re-check as its lightweight safety net. The cost is that
|
|
54
|
+
unregistered files' command edits are not recorded; the benefit is a simple,
|
|
55
|
+
reliable, maintainable plugin — the same behavior Claude Code exhibits.
|
|
56
|
+
|
|
57
|
+
## Rewind precision for a registered file changed outside editing tools
|
|
58
|
+
|
|
59
|
+
A registered file's snapshots come only from (a) an editing tool's before-capture
|
|
60
|
+
and (b) the boundary's change-detection. So a message is a *recording point* for
|
|
61
|
+
a file only when the file's state differs from its most recent recorded state;
|
|
62
|
+
rewinding to message *M* restores each file to the earliest entry at/after *M*.
|
|
63
|
+
|
|
64
|
+
If the file was **unchanged** at *M* (relative to its last record), then *M* is
|
|
65
|
+
**not** a recording point. If the file is later changed by a command/manual
|
|
66
|
+
edit, the earliest entry at/after *M* is the one that recorded the **post-change**
|
|
67
|
+
state — so rewinding to *M* restores that post-change state, not the state the
|
|
68
|
+
file actually held at *M*:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
message 1 write sets f = "X" records before=null (file created)
|
|
72
|
+
message 2 boundary records f = "X" (null → X)
|
|
73
|
+
a command changes f "X" → "Y"
|
|
74
|
+
message 3 boundary records f = "Y" (X → Y)
|
|
75
|
+
rewind to message 2 → restores "X" (correct: f was "X" at message 2)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Now insert one *unchanged* message, and the precision gap appears:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
message 1 write sets f = "X" records before=null (file created)
|
|
82
|
+
message 2 boundary records f = "X" (null → X)
|
|
83
|
+
message 3 reply, f still "X" NOT a recording point (unchanged)
|
|
84
|
+
a command changes f "X" → "Y"
|
|
85
|
+
message 4 boundary records f = "Y" (X → Y)
|
|
86
|
+
rewind to message 3 → restores "Y" (wrong: f was "X" at message 3)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Message 2 is a recording point, so rewinding to it recovers the pre-change state
|
|
90
|
+
`X`; message 3 is not, so its exact state is lost once the command changes the
|
|
91
|
+
file. Only recording every registered file at every message — even when nothing
|
|
92
|
+
changed — would close this, at the cost of a per-message file per registered
|
|
93
|
+
file. The plugin avoids that to stay lightweight (see
|
|
94
|
+
[Position](#position)). Claude Code's checkpoints behave the same way.
|
|
95
|
+
|
|
96
|
+
## Position
|
|
97
|
+
|
|
98
|
+
Making command edits restorable in the *unregistered-first-touch* case would
|
|
99
|
+
mean repeatedly backing up the entire workspace, or depending on a Git working
|
|
100
|
+
tree — both heavy, and at odds with the plugin's lightweight-snapshot design.
|
|
101
|
+
Git is already the right tool for workspace-change management. The plugin's
|
|
102
|
+
position is to favor **fast, conversation-scoped rewinds**, so this capability
|
|
103
|
+
is deliberately not adopted.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# 文件回退的追踪边界
|
|
2
|
+
|
|
3
|
+
> 行为说明——这**不是 bug**。插件的文件回退**有意只追踪模型的专用编辑工具**,并对**已登记文件**额外施加一层边界(消息边界复查)。为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写;与 Claude Code 的 checkpoint 语义保持一致。 [English](tracking-boundary.md)
|
|
4
|
+
|
|
5
|
+
## 一句话概括
|
|
6
|
+
|
|
7
|
+
回退**始终能还原**用专用编辑工具(`write` / `edit` / `str_replace_editor`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
|
|
8
|
+
|
|
9
|
+
## 文件变更的分类
|
|
10
|
+
|
|
11
|
+
**编辑工具**(创建/覆盖、按行编辑、插入):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
|
|
12
|
+
|
|
13
|
+
**命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
|
|
14
|
+
|
|
15
|
+
命令/手动编辑仍有一种情况会被覆盖:若该文件在本会话中**先前被某个编辑工具登记过**,插件会在每个用户消息边界复查已登记文件并记录它看到的变更,因此这类文件也能在回退时还原。
|
|
16
|
+
|
|
17
|
+
## 何时无法回退
|
|
18
|
+
|
|
19
|
+
这类是**完全没有记录到"变更前"状态**(有别于下一节的"精度缺口"——那是有记录、但某个中间消息的精确值丢失):
|
|
20
|
+
|
|
21
|
+
1. **模型第一次触达时用命令编辑,且该文件从未被登记。** 无快照、无登记——变更前后都没有记录,无从还原;回退到任何消息对该文件都报告"无变更可恢复"。
|
|
22
|
+
2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
|
|
23
|
+
|
|
24
|
+
两者都来自**刻意求轻**的取舍。要覆盖各种命令编辑,就得扫描整个工作区、并分类任意命令改了什么——代价高、易错、难维护。插件只登记会交出手确切路径的编辑工具,并以消息边界复查作为轻量安全网。代价是未登记文件的命令编辑不被记录;收益是简单、可靠、易维护——与 Claude Code 相同。
|
|
25
|
+
|
|
26
|
+
## 已登记文件被编辑工具之外的方式改动时的回退精度
|
|
27
|
+
|
|
28
|
+
已登记文件的快照只来自:(a) 编辑工具的 before 捕获,(b) 边界复查的变更检测。所以**只有"文件状态相对上次记录发生变化"的那条消息才是记录点**;回退到消息 *M* 会把各文件还原到 *M* 之后**最早**的那条记录。
|
|
29
|
+
|
|
30
|
+
若文件在 *M* 处**未变**(相对上次记录),则 *M* **不是记录点**。若随后文件被命令/手动编辑改掉,*M* 之后最早的那条记录是**变更后**状态——于是回退到 *M* 会还原成**变更后**状态,而非 *M* 时刻文件**真实**的状态:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
消息 1 write 设 f="X" 记录 before=null(文件新建)
|
|
34
|
+
消息 2 边界记录 f="X" (null→X)
|
|
35
|
+
命令把 f "X"→"Y"
|
|
36
|
+
消息 3 边界记录 f="Y" (X→Y)
|
|
37
|
+
回退到消息 2 → 还原成 "X" (正确:消息 2 时 f 是 "X")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
插入一条**未变**消息,精度缺口才显现:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
消息 1 write 设 f="X" 记录 before=null(文件新建)
|
|
44
|
+
消息 2 边界记录 f="X" (null→X)
|
|
45
|
+
消息 3 回复,f 仍="X" 不是记录点(未变)
|
|
46
|
+
命令把 f "X"→"Y"
|
|
47
|
+
消息 4 边界记录 f="Y" (X→Y)
|
|
48
|
+
回退到消息 3 → 还原成 "Y" (错:消息 3 时 f 实际是 "X")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
消息 2 是记录点,所以回退它能恢复**变更前**状态 `X`;消息 3 不是记录点,一旦文件被命令改掉,它那刻的精确状态就丢了。要关闭这个缺口,就得让每条边界都是快照——每条消息都记录每个已登记文件(哪怕没变)——代价是**每条消息×每个文件都写一个文件**。插件**刻意不做**,以保持轻量(见[定位](#定位))。Claude Code 的 checkpoint 同样如此。
|
|
52
|
+
|
|
53
|
+
## 定位
|
|
54
|
+
|
|
55
|
+
要把"未登记首触即命令编辑"的情形也做成可还原,意味着对整个工作区反复备份,或依赖 Git 工作树——都笨重,且与插件"轻量快照"的设计相悖。Git 才是管理工作区变更的正确工具。插件的定位是**快速、会话内回退**,因此**刻意不采纳**该能力。
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
[简体中文](troubleshooting.zh.md)
|
|
4
|
+
|
|
5
|
+
## History load failure: `…turn-tail… received an update before its start Match`
|
|
6
|
+
|
|
7
|
+
Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
|
|
17
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
|
|
21
|
+
|
|
22
|
+
## Known compatibility boundaries
|
|
23
|
+
|
|
24
|
+
Behavioral notes — none of these is a crash — verified by the compatibility
|
|
25
|
+
probe suites; the audit [audit.md](audit.md) is the source of truth and lists
|
|
26
|
+
the probes that pin each one:
|
|
27
|
+
|
|
28
|
+
- Session stats / telemetry do **not** roll back with a rewind.
|
|
29
|
+
- Withdrawn messages stay **searchable and exported** (`/export` and full-text
|
|
30
|
+
search read the raw log).
|
|
31
|
+
- Session titles may **regenerate** (title derives from the current surface).
|
|
32
|
+
- Files written by a **cancelled tool call** (write happened, no snapshot
|
|
33
|
+
commit) cannot be restored by "conversation and code".
|
|
34
|
+
|
|
35
|
+
**R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying an
|
|
36
|
+
*unclosed* `step/start` (a crash before the agent loop's `finally` closed the
|
|
37
|
+
step) makes later step activity break token-meter replay, so `/compact` can
|
|
38
|
+
fail after a rewind. Harness `0.1.1-rc.2` fixes the crash path on load
|
|
39
|
+
(`interruptedTurnClosers`); a plugin-side up-front rejection was tried and
|
|
40
|
+
**reverted** (`177ec14`, false positives on real logs). Deep analysis:
|
|
41
|
+
[audit.md](audit.md) → R-OPENSTEP.
|
|
@@ -12,3 +12,18 @@ npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-r
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
工具只改写标记事件的 `data.turn`(保持 seq / 顺序 / zstd 帧结构不变),改前自动备份原文件——可安全重复运行。源码方式:v0.4.0 之前的 tag 下 `node scripts/repair-markers.mjs`(参数相同)。
|
|
15
|
+
|
|
16
|
+
## 已知兼容边界
|
|
17
|
+
|
|
18
|
+
以下均为**预期行为而非崩溃**(探针钉住,完整依据见 [audit.md](audit.md) 事实源):
|
|
19
|
+
|
|
20
|
+
- 会话统计 / 遥测**不回退**(折叠的是完整日志);
|
|
21
|
+
- 被撤回的消息**仍可搜索、可导出**(`/export` 与全文搜索基于原始日志);
|
|
22
|
+
- 会话标题可能**重新生成**(标题由当前 surface 派生);
|
|
23
|
+
- 被**取消的工具调用**写入的文件(无快照提交)无法由「回退对话和代码」恢复。
|
|
24
|
+
|
|
25
|
+
**R-OPENSTEP**(harness 侧,插件不设守卫):日志中存在*未闭合* `step/start`
|
|
26
|
+
(agent 循环的 `finally` 闭合 step 前崩溃)时,后续 step 活动会破坏 token-meter
|
|
27
|
+
重放,回退后 `/compact` 可能报错。harness `0.1.1-rc.2` 已在加载时自动闭合
|
|
28
|
+
(`interruptedTurnClosers`);插件曾实现前置拒绝但已回退(`177ec14`,真实日志
|
|
29
|
+
误判)。深入分析:[audit.md](audit.md) → R-OPENSTEP。
|
package/docs/format.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Durable format
|
|
2
|
+
|
|
3
|
+
The on-disk format of the dsh-rewind checkpoint store, pinned as a spec. The
|
|
4
|
+
implementation is `src/snapshot.ts`; this document is the reference for what
|
|
5
|
+
readers may rely on and what a future incompatible change must do. If the code
|
|
6
|
+
and this spec disagree, the code wins and this spec is a bug.
|
|
7
|
+
|
|
8
|
+
## State root
|
|
9
|
+
|
|
10
|
+
The store root defaults to `~/.dsh/rewind-snapshots/` (the dsh data
|
|
11
|
+
directory), overridable via the `DSH_REWIND_SNAPSHOT_DIR` environment
|
|
12
|
+
variable. It is a sibling of the workspace, never a subtree of it. Deleting
|
|
13
|
+
the root only removes file backups; the store rebuilds from scratch.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
<root>/
|
|
17
|
+
└── <sessionId>/ # safeSessionId(sessionId)
|
|
18
|
+
├── <anchorSeq>/ # decimal seq of the anchoring user/message
|
|
19
|
+
│ └── <callId>.json # one committed before-backup
|
|
20
|
+
└── restore-journal-<opId>.json # one restore-op journal
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- `sessionId` is sanitized to `[a-zA-Z0-9._-]`; the bare values `.` and `..`
|
|
24
|
+
are replaced (`safeSessionId`) so a hostile id cannot traverse out of the
|
|
25
|
+
root.
|
|
26
|
+
- `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`).
|
|
27
|
+
- `<anchorSeq>` is a decimal integer; directories with non-integer names are
|
|
28
|
+
ignored by readers.
|
|
29
|
+
- Journal files are recognized by the `restore-journal-` prefix; everything
|
|
30
|
+
else ending in `.json` under the session dir is treated as a checkpoint
|
|
31
|
+
entry.
|
|
32
|
+
|
|
33
|
+
## Checkpoint entry
|
|
34
|
+
|
|
35
|
+
One JSON file per before-backup, named `<callId>.json`:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
interface CheckpointEntry {
|
|
39
|
+
callId: string // the tool call that mutated the file
|
|
40
|
+
anchorSeq: number // seq of the user message anchoring the turn of the change
|
|
41
|
+
path: string // resolved display path (absolute)
|
|
42
|
+
before: string | null // full content before the change; null = file was created
|
|
43
|
+
time: number // epoch ms, strictly increasing within a store instance
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Semantics:
|
|
48
|
+
|
|
49
|
+
- **`before` is the pre-edit state**: `null` means the call created the file.
|
|
50
|
+
- **`anchorSeq` ties the backup to a user message**: rewinding to message N
|
|
51
|
+
applies every entry anchored at or after N (the boundary is inclusive).
|
|
52
|
+
- **`time` is the ordering key within an anchor group**: it is monotonic per
|
|
53
|
+
store instance (bumped past the previous commit), so same-millisecond
|
|
54
|
+
commits stay capture-ordered and a re-read always picks the same "earliest"
|
|
55
|
+
entry per path.
|
|
56
|
+
- Synthetic re-check entries (external edits/deletions seen at a user-message
|
|
57
|
+
boundary) use `callId = recheck-<anchorSeq>-<sha256(path) first 8 hex>`.
|
|
58
|
+
|
|
59
|
+
### Dedup link entry
|
|
60
|
+
|
|
61
|
+
A tracked file that records the same `before` content as its immediately-prior
|
|
62
|
+
entry for that path is stored as a **link** instead of a full copy: the entry
|
|
63
|
+
carries a `ref` (the `<anchorSeq>/<callId>.json` of that prior entry) and omits
|
|
64
|
+
`before`, so identical content is never duplicated across entries. A reader
|
|
65
|
+
resolves the `ref` back to the terminal real snapshot; `before: null` still
|
|
66
|
+
means "the file was created". A `ref` is validated as a single-level,
|
|
67
|
+
`<digits>/<callId>.json` relative reference (no traversal) so a corrupt or
|
|
68
|
+
hostile ref cannot escape the store root when followed. Because links reference
|
|
69
|
+
prior entries, `prune` materializes a surviving link whose `ref` lands on a
|
|
70
|
+
group it is about to drop before deleting that group, so no kept link is left
|
|
71
|
+
dangling.
|
|
72
|
+
|
|
73
|
+
Real entries (with `before`) are unchanged and read identically before and
|
|
74
|
+
after this addition; a link entry is a distinct kind that lacks `before`.
|
|
75
|
+
|
|
76
|
+
## Restore journal
|
|
77
|
+
|
|
78
|
+
One JSON file per restore operation, written **before any mutation** and
|
|
79
|
+
updated as the pass applies:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
interface RestoreJournal {
|
|
83
|
+
version: 1
|
|
84
|
+
id: string // `op-<base36 ms>-<random>`; file name suffix
|
|
85
|
+
sessionId: string
|
|
86
|
+
targetSeq: number // rewind target the restore belongs to
|
|
87
|
+
startedAt: number // epoch ms
|
|
88
|
+
finishedAt?: number // set on a terminal state
|
|
89
|
+
state: 'running' | 'rollback-running' | 'completed' | 'rolled-back' | 'recovery-required'
|
|
90
|
+
actions: RestoreJournalAction[]
|
|
91
|
+
rollbackError?: string // set when a rollback pass failed partway
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface RestoreJournalAction {
|
|
95
|
+
path: string
|
|
96
|
+
action: 'restore' | 'delete' // restore = write `before` back; delete = unlink
|
|
97
|
+
before: string | null // target content for restore; null for delete
|
|
98
|
+
rescue: string | null // pre-restore disk state; null = file was absent
|
|
99
|
+
rescueError?: string // set when the rescue capture failed (rollback skips it)
|
|
100
|
+
done: boolean // true once the action's fs op completed and was marked
|
|
101
|
+
failed?: string // per-action failure message (the pass never aborts)
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
States: `running` and `rollback-running` are non-terminal; a host restart turns
|
|
106
|
+
them into `interrupted` (or `recovery-required` when the journal is corrupt or
|
|
107
|
+
a rollback could not complete). `completed` / `rolled-back` are terminal.
|
|
108
|
+
|
|
109
|
+
## Write guarantees
|
|
110
|
+
|
|
111
|
+
- **Atomicity**: every JSON write serializes to a sibling `<target>.tmp` and
|
|
112
|
+
renames over the target. A crash between the two steps leaves only the temp
|
|
113
|
+
file — never a readable half-written target — and readers ignore temp files
|
|
114
|
+
(they do not end in `.json`). The next write of the same target overwrites
|
|
115
|
+
a leftover temp.
|
|
116
|
+
- **Journal before mutation**: the rescue state of every planned path is
|
|
117
|
+
captured and the intent journal persisted atomically BEFORE the first fs
|
|
118
|
+
mutation; each action is marked `done` as it is applied.
|
|
119
|
+
- **Disk is truth**: after a restart, reconciliation compares the real disk
|
|
120
|
+
against each action's goal (the restore target for `running` journals, the
|
|
121
|
+
rescue state for `rollback-running` / `recovery-required` ones). A path
|
|
122
|
+
whose disk already matches is marked done without being touched.
|
|
123
|
+
- **Bounded storage**: `prune` keeps the newest 100 anchor groups per session
|
|
124
|
+
(`MAX_ANCHOR_GROUPS`), materializing any surviving dedup link that references
|
|
125
|
+
a group being dropped before deleting whole anchor directories; it also
|
|
126
|
+
recycles terminal journals (`completed` / `rolled-back`). Non-terminal and
|
|
127
|
+
corrupt journals are always kept. Across sessions, `pruneStale` removes whole
|
|
128
|
+
long-inactive session directories whose newest member stamp is older than a
|
|
129
|
+
configurable idle cutoff (default off), so the store root does not grow
|
|
130
|
+
without bound either.
|
|
131
|
+
|
|
132
|
+
## Validation and failure policy
|
|
133
|
+
|
|
134
|
+
- **Entries**: a missing or malformed entry is read as `undefined` (silently
|
|
135
|
+
skipped) — losing one backup, never the recovery path.
|
|
136
|
+
- **Journals**: a corrupt or schema-invalid journal **fails loud** —
|
|
137
|
+
`reconcileRestores` reports it as `recovery-required` and never drops it,
|
|
138
|
+
because dropping it would silently erase the interrupted restore's recovery
|
|
139
|
+
record.
|
|
140
|
+
- **Journal IO**: best-effort by design — if a journal cannot be written, the
|
|
141
|
+
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
142
|
+
behavior does not).
|
|
143
|
+
|
|
144
|
+
## Versioning policy
|
|
145
|
+
|
|
146
|
+
The journal schema is `version: 1`. Checkpoint entries currently carry no
|
|
147
|
+
version field. A future incompatible format must either bump the journal
|
|
148
|
+
`version` (readers reject unknown values — there is no best-effort fallback or
|
|
149
|
+
legacy coercion) or move the state root (e.g. `rewind-snapshots/v2`) and ship
|
|
150
|
+
an explicit migration tool. Old-format data is never silently re-interpreted.
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
Compatibility probes against these subsystems (test-driven investigation, the
|
|
23
23
|
`compat-invariants` / `compat-interop` vitest suites + the `verify-host` real
|
|
24
|
-
`/compact` chain): [compat
|
|
24
|
+
`/compact` chain): [compat/audit.md](compat/audit.md).
|
|
25
25
|
|
|
26
26
|
Also under `docs/` at the repo root: `persistence-catalog.md` (full
|
|
27
27
|
`SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
|
|
@@ -45,6 +45,10 @@ Also under `docs/` at the repo root: `persistence-catalog.md` (full
|
|
|
45
45
|
|
|
46
46
|
## Plugin source layout
|
|
47
47
|
|
|
48
|
+
The module map below duplicates `AGENTS.md` (`Layout at a glance`), which is
|
|
49
|
+
the canonical source; this block only adds the finer-grained client-side files
|
|
50
|
+
and packaging entries.
|
|
51
|
+
|
|
48
52
|
```
|
|
49
53
|
src/index.ts host plugin: /rewind command + checkpoint pipeline (tools/execute|post-execute)
|
|
50
54
|
src/rewind.ts pure planning: target resolution, surface range, candidate listing
|
|
@@ -56,9 +60,9 @@ src/client/hidden.ts withdrawn-span computation (hiddenSeqsOf), pure
|
|
|
56
60
|
src/client/locales.ts zh / en copy (LocaleNamespaceMap)
|
|
57
61
|
src/client/styles.ts injected styles (dsh design tokens)
|
|
58
62
|
scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
59
|
-
scripts/verify-host.mjs end-to-end host verification (
|
|
63
|
+
scripts/verify-host.mjs end-to-end host verification (full check suite)
|
|
60
64
|
tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
|
|
61
|
-
docs/ maintainer docs:
|
|
65
|
+
docs/ maintainer docs: contract/, compat/, release/ subdirectories
|
|
62
66
|
assets/screenshots/ UI screenshots
|
|
63
67
|
cordis.patch.yml bundle patch (mounts the dual-face plugin row)
|
|
64
68
|
package.json dsh.bundle + dsh.client manifests, optional peerDependencies
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Release
|
|
2
|
+
|
|
3
|
+
[简体中文](release.zh.md)
|
|
4
|
+
|
|
5
|
+
## First release (manual, one-time)
|
|
6
|
+
|
|
7
|
+
Trusted Publisher can only be configured once the package exists, so the first
|
|
8
|
+
version is published locally:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm login
|
|
12
|
+
npm publish --access public
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- If prompted for `EOTP`: complete the browser auth link the CLI prints, or
|
|
16
|
+
retry with a 6-digit code — `npm publish --otp=<code>`.
|
|
17
|
+
- The first version carries no provenance (local path) — acceptable; every CI
|
|
18
|
+
release after that publishes with Sigstore/SLSA provenance automatically.
|
|
19
|
+
|
|
20
|
+
## Configure Trusted Publisher (npmjs.com, one-time)
|
|
21
|
+
|
|
22
|
+
Open `https://www.npmjs.com/package/dsh-rewind-plugin` → package **settings** →
|
|
23
|
+
**Trusted Publisher**:
|
|
24
|
+
|
|
25
|
+
| Field | Value |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Provider | GitHub Actions |
|
|
28
|
+
| Organization or user | `SiriLee` |
|
|
29
|
+
| Repository | `dsh-rewind` (the GitHub repo, not the npm name) |
|
|
30
|
+
| Workflow filename | `publish.yml` |
|
|
31
|
+
| Environment | empty |
|
|
32
|
+
| Allowed actions | `npm publish` |
|
|
33
|
+
|
|
34
|
+
## Subsequent releases (CI, automatic)
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npm version patch
|
|
38
|
+
git push origin main --tags # triggers .github/workflows/publish.yml
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- The workflow verifies the tag matches `package.json`, runs typecheck + tests +
|
|
42
|
+
a full build + artifact verification, publishes with `--provenance`
|
|
43
|
+
(Sigstore), and creates a GitHub Release. It is **idempotent** — an already
|
|
44
|
+
published version is skipped.
|
|
45
|
+
- CI (`.github/workflows/ci.yml`) runs `npm run check` — typecheck + tests +
|
|
46
|
+
build + artifact verification + a `npm pack --dry-run` — on every push / PR
|
|
47
|
+
across both Node engines boundary versions; the tarball layout is guarded by
|
|
48
|
+
`tests/package-layout.test.ts`.
|
|
49
|
+
|
|
50
|
+
## DSH version alignment (peer range maintenance)
|
|
51
|
+
|
|
52
|
+
DSH is still in rc; npm's prerelease matching rules require a peer range to
|
|
53
|
+
share the host version's `[major, minor, patch]` tuple. So `peerDependencies`
|
|
54
|
+
uses an OR-union covering every published rc tuple series
|
|
55
|
+
(e.g. `^0.1.0-rc.6 || ^0.1.1-rc.2`), extended as DSH releases new tuples.
|
|
56
|
+
|
|
57
|
+
- **When to update**: only when DSH releases a new tuple
|
|
58
|
+
(`0.1.1 → 0.1.2 → 0.2.x`); rc rolling within a tuple (`0.1.1-rc.2 → rc.3`)
|
|
59
|
+
needs nothing. All `@deepseek-ai/*` packages release together;
|
|
60
|
+
`npm view @deepseek-ai/dsh version` is the authoritative signal.
|
|
61
|
+
- **Automatic detection**: `node scripts/check-dsh-version.mjs` compares the
|
|
62
|
+
latest npm version against the tuples the peers cover and reports whether an
|
|
63
|
+
extension is needed (exit 0 = nothing to do, exit 1 = update).
|
|
64
|
+
- **Update steps**: append `|| ^<new-tuple>-rc.<n>` to every
|
|
65
|
+
`@deepseek-ai/dsh-*` peer → bump devDependencies to the latest → `npm
|
|
66
|
+
install` → `npm run check` → release.
|
|
67
|
+
- **After DSH goes final**: final releases are not bound by the prerelease
|
|
68
|
+
tuple rule, so the peers can converge to a single stable range (e.g.
|
|
69
|
+
`^0.1.x`); this section can then be deleted.
|