dsh-rewind-plugin 0.14.0-beta.2 → 0.14.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/README.en.md +4 -4
- package/README.md +4 -4
- package/SECURITY.md +1 -1
- package/docs/compat/audit.md +2 -1
- package/docs/compat/tracking-boundary.md +218 -101
- package/docs/compat/tracking-boundary.zh.md +110 -34
- package/lib/client.js +26 -7
- package/lib/index.js +90 -6
- package/lib/types/client/portals.d.ts +4 -0
- package/lib/types/client/styles.d.ts +10 -2
- package/lib/types/locales.d.ts +4 -0
- package/lib/types/snapshot.d.ts +43 -0
- package/package.json +3 -3
package/README.en.md
CHANGED
|
@@ -37,7 +37,7 @@ Check your local DSH version, then find the matching plugin version in
|
|
|
37
37
|
**Command line**:
|
|
38
38
|
|
|
39
39
|
```sh
|
|
40
|
-
dsh plugin --profile
|
|
40
|
+
dsh plugin --profile <name> add dsh-rewind-plugin@<version>
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
**Graphical interface**: the **Plugins** page in the sidebar → **Add plugin** → enter `dsh-rewind-plugin@<version>` → install → restart DSH and refresh the browser.
|
|
@@ -91,7 +91,7 @@ auto-cleanup switch and the idle-day cutoff), or use the
|
|
|
91
91
|
|
|
92
92
|
```sh
|
|
93
93
|
# Uninstall the plugin
|
|
94
|
-
dsh plugin --profile
|
|
94
|
+
dsh plugin --profile <name> remove dsh-rewind-plugin
|
|
95
95
|
|
|
96
96
|
# To also delete local data
|
|
97
97
|
rm -rf <dsh home>/rewind-snapshots
|
|
@@ -131,10 +131,10 @@ The whole design rests on two principles, simple but deliberate: **the conversat
|
|
|
131
131
|
|
|
132
132
|
The file half follows Claude Code's checkpoint semantics — **partial tracking + before-write backup, plus a re-scan of tracked files at each message**, not a whole-tree snapshot. This trade-off saves space, and it's actually more complete:
|
|
133
133
|
|
|
134
|
-
- **Before-write backup**: tracks only the write-class tools (`write`, `edit`) — backs up the original content before a write and records/tracks the files it touches; it never backs up the whole workspace, so it's lightweight
|
|
134
|
+
- **Before-write backup**: tracks only the write-class tools (`write`, `edit`) — backs up the original content before a write and records/tracks the files it touches; it never backs up the whole workspace, so it's lightweight; **a single file that is too large is not backed up**.
|
|
135
135
|
- **External changes count too**: at every user-message boundary the plugin re-checks all tracked files — external changes such as a command run or a manual edit are recorded as well and restored by a later rewind. "Lightweight" but not "incomplete".
|
|
136
136
|
- **Unchanged-not-recorded**: an entry is written only when something changed — at the message-boundary re-check, an unchanged file is never backed up (no record); at before-write time, when the new content matches the path's prior record, only a **link to it** (`ref`) is stored instead of a copy.
|
|
137
|
-
- **Accurate restore**: backups are the sole standard, checked against the real disk — **only files that actually differ are touched
|
|
137
|
+
- **Accurate restore**: backups are the sole standard, checked against the real disk — **only files that actually differ are touched**. Backups are stored byte for byte, so the restored result matches the backups exactly, with no "ghost impact". There is a limit, though: [File-rewind tracking boundary](docs/compat/tracking-boundary.md).
|
|
138
138
|
- **Safety and integrity**: paths are sanitized so nothing ever escapes the backup root; symlinks / hard links are skipped so one restore can't clobber another name of the same file; a per-file failure never aborts the pass; backups and the restore journal are written atomically and kept across restarts, so a half-applied restore after a crash can be continued or rolled back.
|
|
139
139
|
|
|
140
140
|
> **Design highlight**: this checkpoint's light footprint comes from **recording only what was actually touched and really changed** — before-write backup makes it restorable, unchanged-not-recorded and content-as-link drop the repetition; only the files that differ are touched at restore time.
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ DeepSeek Harness 插件:**一键就地回退对话到任意更早的用户消
|
|
|
36
36
|
**命令行**:
|
|
37
37
|
|
|
38
38
|
```sh
|
|
39
|
-
dsh plugin --profile
|
|
39
|
+
dsh plugin --profile <name> add dsh-rewind-plugin@<版本>
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
**图形界面**:侧栏「插件」页 →「添加插件」→ 填 `dsh-rewind-plugin@<版本>` → 安装 → 重启 DSH 并刷新浏览器。
|
|
@@ -79,7 +79,7 @@ dsh plugin --profile web add dsh-rewind-plugin@<版本>
|
|
|
79
79
|
|
|
80
80
|
```sh
|
|
81
81
|
# 卸载插件
|
|
82
|
-
dsh plugin --profile
|
|
82
|
+
dsh plugin --profile <name> remove dsh-rewind-plugin
|
|
83
83
|
|
|
84
84
|
# 如需同时删除本地数据
|
|
85
85
|
rm -rf <dsh home>/rewind-snapshots
|
|
@@ -118,10 +118,10 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
|
|
|
118
118
|
|
|
119
119
|
文件部分对齐 Claude Code 的检查点语义——**局部追踪、写前备份 + 每条消息重扫已跟踪文件**,而不是整树快照。这项取舍既省空间,又更完整:
|
|
120
120
|
|
|
121
|
-
- **写前备份**:只追踪写类工具(`write`、`edit
|
|
121
|
+
- **写前备份**:只追踪写类工具(`write`、`edit`),写前**备份原内容**,并**记录、追踪**被处理的文件——从不备份整个工作区,因此轻量;**单个文件过大时不备份**。
|
|
122
122
|
- **外部变更也追**:每条用户消息边界,插件重新检查所有已跟踪文件——命令执行、手动修改等外部变更同样被记录,回退时一并还原。这让「轻量」却不「残缺」。
|
|
123
123
|
- **不变不存**:记录只在有变化时发生——消息边界重扫时无变更的**不备份**(不留记录);写前备份时若与前一条记录一致,只存指向它的链接(`ref`)而非复制内容。
|
|
124
|
-
-
|
|
124
|
+
- **还原准确**:以备份为唯一标准,对照真实磁盘,只动真正不一致的文件;备份逐字节存储。还原结果与备份一致,无“幽灵影响”。但存在限制:[文件回退的追踪边界](docs/compat/tracking-boundary.zh.md)。
|
|
125
125
|
- **安全与完整性**:路径经安全化处理,绝不越出备份根目录;符号/硬链接跳过,避免透过一次还原误伤同名的另一份文件;单个文件失败绝不中止整轮还原;备份与还原日志均原子写落盘(跨重启仍在),断电或崩溃后的半还原可续做或回滚。
|
|
126
126
|
|
|
127
127
|
> **设计点睛**:这套检查点的「轻」,来自**只记录被工具动过、且确实变化的文件**——写前备份保证可还原,不变不存与存链接压掉重复;还原时再对照真实磁盘,只动不一致的文件。
|
package/SECURITY.md
CHANGED
|
@@ -175,7 +175,7 @@ transcript.
|
|
|
175
175
|
distinct cases follow:
|
|
176
176
|
1. **Sensitive / personal-information files** (e.g. a `.env` file, which may
|
|
177
177
|
sit inside the workspace) the model may read or edit are backed up and
|
|
178
|
-
restorable.
|
|
178
|
+
restorable, unless the file is over the size cap.
|
|
179
179
|
2. **Files outside the session workspace** the model is allowed to touch are
|
|
180
180
|
likewise backed up and restored.
|
|
181
181
|
In both cases the plugin introduces no authority of its own — DSH already
|
package/docs/compat/audit.md
CHANGED
|
@@ -58,7 +58,7 @@ legacy branch).
|
|
|
58
58
|
- **agent-loop cancellation**: `finally` guarantees step/turn closure; the rewind force-stop path leaves no dangling frame.
|
|
59
59
|
- **settings card (`0.1.7-alpha.1`)**: the card declares `configForms` as a module-level inject (the web profile always composes `ui-settings`), stages through the harness's own `SettingsFormModel` + `SettingsForm`, and registers into `plugins.bundle.config` keyed by the bundle package name; it reads that shared per-entry form's snapshot and writes through its revision-fenced `mutate`, disposing the model's subscription with the fiber. The host half declares the bundle's page policy (`configure({ auto: false }, ctx.fiber)` inside a `ctx.inject(['settings'])` child, per the dsh-settings README), opting the entry out of a client-built schema page. Pins: `verify:host` (`the bundle declares its own config page`), `tests/client-settings-card.test.ts` (staged plan, refusal, subscription release), `tests/client-lifecycle.test.ts`.
|
|
60
60
|
- **client session ownership (`0.1.6-alpha.2`)**: `SessionListState.current` / `currentAddress` are gone; main-view ownership is read from the row's `retainedBy.mainView` label — what `ui-workspace` retains the open session with. Pin: `tests/client-refill.test.ts`.
|
|
61
|
-
- **client pending input (`0.1.6-alpha.2`)**: `SessionSnapshot.queue` and the host `queue-mirror` are gone; pending steering rows come from the session's own `inbox` projection, and only USER-sourced `next-step` rows are retractable (the removed mapping was `source.kind === 'user' ? 'steering' : 'context'`). Pins: `tests/pending.test.ts`, `tests/client-retract.test.ts`.
|
|
61
|
+
- **client pending input (`0.1.6-alpha.2`)**: `SessionSnapshot.queue` and the host `queue-mirror` are gone; pending steering rows come from the session's own `inbox` projection, and only USER-sourced `next-step` rows are retractable (the removed mapping was `source.kind === 'user' ? 'steering' : 'context'`). The rendered bubble also stays the client's own submission echo until the durable node arrives (`ChatView.pendingInputs` merges the Host occurrence into that echo by `rpcId`; `QueueDock` mirrors only `next-turn`), so the occurrence lands with NO DOM mutation — the portal refresh therefore follows the `inbox` face's own `subscribe` beside the DOM observer. Pins: `tests/pending.test.ts`, `tests/client-retract.test.ts`, `tests/client-portal-refresh.test.ts`.
|
|
62
62
|
- **client settings slot (`0.1.6-alpha.2`)**: `settings.plugin.item` is gone; the configuration form registers into `plugins.bundle.config`, keyed by the bundle package name. Pin: `tests/client-contract.test.ts`.
|
|
63
63
|
- **`agent/created` lifecycle guard**: the session-format reconcile runs on the alpha line's `agent/created` (fire-and-forget); pin: `verify-host` 4e dispatches it.
|
|
64
64
|
- **marker vs `/compact`**: the checkpoint shape is unchanged on this line — a `user/message` replace (`surfaceOp {replace, startSeq, endSeq}` + `sourceEventSeqs`); `assistant/message` still cannot carry `sourceEventSeqs`.
|
|
@@ -79,6 +79,7 @@ legacy branch).
|
|
|
79
79
|
- **Rewind leaves plan mode untouched**: `/plan text` is two independent actions (enter plan mode + steer the message). Rewinding the message undoes only the message — the log-only `plan/mode` state stays active, and the user leaves plan mode with `/plan off`, which still commits after a rewind (the marker creates no open turn). Pin: `verify-host` plan checks (`plan rewind leaves plan mode active`, `/plan off after rewind turns plan mode off`), `tests/hidden.test.ts` `messageTextAt`.
|
|
80
80
|
- **Bundle card metadata is declared by the plugin, not by the manager (`0.1.7-alpha.1`)**: the manager still titles a bundle with `shortName(pkg.name)` and its raw `package.json` description when nothing else is declared, but it now reads plugin-owned resources first — `locale/<lang>.json` (`meta.title`/`meta.description`) through the package exports map, and an `icon` file relative to the manifest. This plugin declares both, so its card shows an icon and copy that follows the active language; only the fallback description remains mixed-language. Pin: `tests/package-layout.test.ts` (icon path/media/size and both dictionaries).
|
|
81
81
|
- **Synchronous Session history reads are deprecated** on this line (`snapshotEvents` / `eventAt` / `ownEvents`): existing calls may remain, new calls are prohibited. The plugin keeps its existing reads (turn anchor + candidate listing); the migration path is a `ctx.sessionProjections` projection unit or the async paged history read this line has not shipped yet — not a plugin-side index.
|
|
82
|
+
- **A peer version range is never enforced at runtime**, and a physical copy nearer than the host still wins (upstream profile-resolution lookup order: "a peer version range is not an additional resolver filter"; "a nearer physical candidate wins"). So the `@deepseek-ai/schemastery` peer floor *is* the availability guarantee for `.volatile()` (#40): a plugin directory or linked checkout left holding an older copy (e.g. the pre-3.18.2 devDependency) shadows the host's 3.18.4 and fails the top-level `Config` evaluation before the plugin loads. Floor pinned at `^3.18.4`.
|
|
82
83
|
|
|
83
84
|
## Upstream (harness) issues and the plugin's no-compensation stance
|
|
84
85
|
|
|
@@ -1,103 +1,220 @@
|
|
|
1
1
|
# File-rewind tracking boundary
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
5
|
-
>
|
|
6
|
-
>
|
|
7
|
-
>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
message
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
message
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
3
|
+
> When it rewinds files, the plugin **writes back only content it actually backed
|
|
4
|
+
> up**: a file changed by a write-class tool (`write`, `edit`) can always be restored
|
|
5
|
+
> exactly, while a stretch of state that **was not backed up** makes the restore
|
|
6
|
+
> **incomplete** — it prefers to do nothing rather than guess.
|
|
7
|
+
>
|
|
8
|
+
> This document describes how it works, the two known limitations that follow, and
|
|
9
|
+
> why they are accepted. All of it is a **deliberate trade-off** (matching Claude
|
|
10
|
+
> Code's checkpoint semantics), not a defect.
|
|
11
|
+
|
|
12
|
+
## 1. The two modes of file change
|
|
13
|
+
|
|
14
|
+
A file in DSH can change in exactly two ways, and the plugin observes them very
|
|
15
|
+
differently.
|
|
16
|
+
|
|
17
|
+
**The model calls a write-class tool** (`write`, `edit`): the call's arguments carry
|
|
18
|
+
the exact path, so the plugin can capture the file's current content **before** the
|
|
19
|
+
write lands. This path is deterministic — once handled, the file is certainly
|
|
20
|
+
restorable.
|
|
21
|
+
|
|
22
|
+
**An external edit** (a command-line Bash, a PowerShell write, a build script
|
|
23
|
+
rewriting files, or saving by hand in an editor): nothing declares the modified path
|
|
24
|
+
to the plugin, so **there is no object to capture in advance**; the plugin can only
|
|
25
|
+
discover it after the fact.
|
|
26
|
+
|
|
27
|
+
## 2. How it works
|
|
28
|
+
|
|
29
|
+
### One backup = the state when one message began
|
|
30
|
+
|
|
31
|
+
The plugin does not store the workspace's current state; it stores **state
|
|
32
|
+
transitions**. Every backup answers one question:
|
|
33
|
+
|
|
34
|
+
> **What was this file when that message began?**
|
|
35
|
+
|
|
36
|
+
Its content has two forms: `null` means the file **did not exist** (a restore then
|
|
37
|
+
deletes it), otherwise it is a full copy. Together these backups are the session's
|
|
38
|
+
**snapshot** (grouped by message). A backup belongs to **the user message that
|
|
39
|
+
triggered it**; within a single message's turn, every write-class tool call leaves a
|
|
40
|
+
backup of its own, and a restore takes the **earliest** of them.
|
|
41
|
+
|
|
42
|
+
### Where backups come from: the tracked list
|
|
43
|
+
|
|
44
|
+
The plugin does not scan the workspace; it follows one **tracked list**: a file joins
|
|
45
|
+
it as soon as a write-class tool handles it **once**; from then on, every new user
|
|
46
|
+
message makes the plugin re-check the files **on that list**; a file that never joins
|
|
47
|
+
it has no backup at all.
|
|
48
|
+
|
|
49
|
+
Backups come from two sources:
|
|
50
|
+
|
|
51
|
+
- **Before-write backup**: when the model calls a write-class tool, the plugin takes
|
|
52
|
+
the current content **before** the change lands and stores it under **the user
|
|
53
|
+
message that started this turn**;
|
|
54
|
+
- **Message-boundary re-check**: when a new message arrives, if a file on the list
|
|
55
|
+
now differs from its **latest backup**, the current content is recorded as **that
|
|
56
|
+
message's** backup. This is the only way an external edit gets backed up.
|
|
57
|
+
|
|
58
|
+
### Two cases where no backup is produced
|
|
59
|
+
|
|
60
|
+
- **Unchanged, not stored**: when the boundary re-check finds the disk identical to
|
|
61
|
+
the latest backup, this re-check writes nothing; if a write-class tool edits that
|
|
62
|
+
file later in the same turn, that before-write backup is still recorded. A message
|
|
63
|
+
that changed no file therefore produces no backup of its own.
|
|
64
|
+
- **Over the cap, not stored**: before writing a backup, if a single file is found to
|
|
65
|
+
exceed the size cap (8 MiB by default, adjustable via `DSH_REWIND_MAX_FILE_BYTES`,
|
|
66
|
+
`0` disables it), the plugin **refuses the backup**; until that file is back under
|
|
67
|
+
the cap, no new backup is written for it (the earlier backups remain).
|
|
68
|
+
|
|
69
|
+
### Restore: backups are the only standard
|
|
70
|
+
|
|
71
|
+
A restore reads nothing else in the workspace and infers nothing:
|
|
72
|
+
|
|
73
|
+
1. **Find the backup**: per file, take the **earliest** backup at or after the
|
|
74
|
+
**target message**; when the target message has no backup of its own, look toward
|
|
75
|
+
the later messages for the earliest one.
|
|
76
|
+
2. **Compare with the disk**: if it matches the current disk content, nothing is
|
|
77
|
+
done; otherwise the backup's content is written back (a `null` backup deletes the
|
|
78
|
+
file).
|
|
79
|
+
|
|
80
|
+
Looking toward later messages works because "unchanged, not stored" means the target
|
|
81
|
+
message began from the same state as its latest backup — so the earliest later backup
|
|
82
|
+
holds exactly that state (the premise being that nothing between the target message
|
|
83
|
+
and that backup went unrecorded; see limitation 2). Conversely, the nearest backup
|
|
84
|
+
*before* the target is not used because it may predate a **coverage gap** (limitation
|
|
85
|
+
2, case 3): writing it back would overwrite the current file with an older version.
|
|
86
|
+
**Restore less rather than write something wrong.**
|
|
87
|
+
|
|
88
|
+
### An example
|
|
89
|
+
|
|
90
|
+
| Msg | In the turn | File content | Backup: what it was when that message began |
|
|
91
|
+
| :---: | --- | :---: | --- |
|
|
92
|
+
| 1 | `write` creates it | `—` → `A` | `null` (the file did not exist before the write; backed up before the write and added to tracking) |
|
|
93
|
+
| 2 | — | `A` | `A` (the disk differs from the previous backup `null`) |
|
|
94
|
+
| 3 | — | `A` | (none: identical to the latest backup, and nothing was edited in this turn, unchanged, not stored) |
|
|
95
|
+
| 4 | `edit` | `A` → `B` | `A` (the content before that edit, a before-write backup) |
|
|
96
|
+
| 5 | `bash` rewrite | `B` → `C` | `B` (msg 4's edit had already taken effect, so the disk held `B` when msg 5 arrived) |
|
|
97
|
+
|
|
98
|
+
The disk now holds `C`. Rewinding gives: target 1 → the backup is `null`, the file is
|
|
99
|
+
deleted; target 2 → `A`; target 3 → no backup of its own, so looking toward later
|
|
100
|
+
messages finds msg 4's `A`, exactly the state msg 3 began from; target 4 → `A`;
|
|
101
|
+
target 5 → `B`.
|
|
102
|
+
|
|
103
|
+
## 3. Known limitations
|
|
104
|
+
|
|
105
|
+
Both limitations share one root cause: **a stretch of state was not backed up**.
|
|
106
|
+
Limitation 1 results in **no restore at all**, limitation 2 in an **incomplete
|
|
107
|
+
restore**.
|
|
108
|
+
|
|
109
|
+
### Limitation 1: history that never entered tracking — no restore
|
|
110
|
+
|
|
111
|
+
**Being handled by a write-class tool is the prerequisite for a file to join the
|
|
112
|
+
tracked list and produce backups.** Before that, every external edit it went through
|
|
113
|
+
has no backup, and neither source will write one for it. Two cases:
|
|
114
|
+
|
|
115
|
+
**Nothing in the session ever handled it.** Rewinding to any message can only report
|
|
116
|
+
"no restorable changes" for that file.
|
|
117
|
+
|
|
118
|
+
**Something handled it later (the more common case).** Backups exist only from that
|
|
119
|
+
moment on, so earlier history cannot be restored — in the example below, `v0` is
|
|
120
|
+
history the plugin has never seen:
|
|
121
|
+
|
|
122
|
+
| Msg | In the turn | File content | Backup: what it was when that message began |
|
|
123
|
+
| :---: | --- | :---: | --- |
|
|
124
|
+
| 1 | — | `v0` | (none: the file has not joined the tracked list) |
|
|
125
|
+
| 2 | a manual edit | `v0` → `v1` | (none: as above) |
|
|
126
|
+
| 3 | a manual edit | `v1` → `v2` | (none: as above) |
|
|
127
|
+
| 4 | `edit`, the first time it is handled | `v2` → `v3` | `v2` (before-write backup: the file joins the tracked list at this moment) |
|
|
128
|
+
| 5 | — | `v3` | `v3` (the disk differs from the previous backup `v2`) |
|
|
129
|
+
|
|
130
|
+
Rewinding to msg 5 → `v3`; to msg 4 → `v2`; and **rewinding to msg 1, 2 or 3 also
|
|
131
|
+
gives `v2`** — the earliest backup after them is msg 4's `v2`. `v2` really is the
|
|
132
|
+
content at the moment the file was first handled, but what those three messages began
|
|
133
|
+
from was `v0`, `v0` and `v1`; the plugin never saw them and cannot restore them.
|
|
134
|
+
|
|
135
|
+
### Limitation 2: a stretch of state that was not backed up — an incomplete restore
|
|
136
|
+
|
|
137
|
+
Both "unchanged, not stored" and "over the cap, not stored" leave a stretch of state
|
|
138
|
+
without a backup. When the target message has no backup of its own, the plugin looks
|
|
139
|
+
toward later messages for the backup; as soon as an **unrecorded change** happened
|
|
140
|
+
between the target and that backup, what it takes is no longer the state the target
|
|
141
|
+
message began from. The three cases below stack one more scenario each.
|
|
142
|
+
|
|
143
|
+
**Case 1: the change does not belong to any message yet**
|
|
144
|
+
|
|
145
|
+
| Msg | In the turn | File content | Backup |
|
|
146
|
+
| :---: | --- | :---: | --- |
|
|
147
|
+
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
148
|
+
| 2 | — | `A` | `A` (differs from the previous backup `null`) |
|
|
149
|
+
| 3 | — | `A` | (none: identical to the previous backup, and nothing was edited in this turn) |
|
|
150
|
+
| (after) | a manual edit | `A` → `X` | (none: no new message has claimed it yet) |
|
|
151
|
+
|
|
152
|
+
Rewinding to msg 3: neither it nor anything later has a backup, the plugin does not
|
|
153
|
+
know the file changed, so it **does nothing and the file stays at `X`** (msg 3
|
|
154
|
+
actually began with `A`). To recover `A`, rewind to msg 2 — the backup under it is
|
|
155
|
+
`A`. A later message only makes the state **at that time** (`X`) rewindable; the `A`
|
|
156
|
+
msg 3 began from still cannot come back.
|
|
157
|
+
|
|
158
|
+
**Case 2: what is backed up is the state after the change**
|
|
159
|
+
|
|
160
|
+
| Msg | In the turn | File content | Backup |
|
|
161
|
+
| :---: | --- | :---: | --- |
|
|
162
|
+
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
163
|
+
| 2 | — | `A` | `A` (differs from the previous backup `null`) |
|
|
164
|
+
| 3 | — | `A` | (none: identical to the latest backup) |
|
|
165
|
+
| 4 | a command changes it to `B` | `A` → `B` | (none: the disk was still `A` when msg 4 arrived) |
|
|
166
|
+
| 5 | — | `B` | `B` (differs from the previous backup `A`) |
|
|
167
|
+
|
|
168
|
+
Rewinding to msg 3 or 4: neither has a backup of its own, so looking toward later
|
|
169
|
+
messages finds msg 5's `B` — the state **after** the change, while they began with
|
|
170
|
+
`A`. The disk matches that backup, so the plugin does nothing and the file stays at
|
|
171
|
+
`B`; if the file changed again afterwards, the plugin writes `B` (overwriting
|
|
172
|
+
whatever is newer); and if the change in msg 4's turn had been a command **deleting**
|
|
173
|
+
the file, msg 5's backup would be `null` and rewinding to 3 or 4 would **delete the
|
|
174
|
+
current file**. The difference from case 1 is that a message followed this change and
|
|
175
|
+
recorded the state after it — in case 1 nothing was recorded at all.
|
|
176
|
+
|
|
177
|
+
**Case 3: the file exceeded the cap while it changed**
|
|
178
|
+
|
|
179
|
+
| Msg | In the turn | File content | Backup |
|
|
180
|
+
| :---: | --- | :---: | --- |
|
|
181
|
+
| 1 | `write` creates it | `—` → `A` | `null` |
|
|
182
|
+
| 2 | the edit grows it past the cap | `A` → `G1` | `A` (differs from the previous backup `null`) |
|
|
183
|
+
| 3 | a write-class tool or an external edit | `G1` → `G2` | (none: over the cap, not backed up) |
|
|
184
|
+
| 4 | shrinks back to `B` | `G2` → `B` | (none: still over the cap on arrival, so the re-check skipped it) |
|
|
185
|
+
| 5 | `edit` | `B` → `X` | `B` (a before-write backup; tracking resumes) |
|
|
186
|
+
|
|
187
|
+
In the "small → big → small" scenario the stretch over the cap has no backup at all —
|
|
188
|
+
a **coverage gap**. Rewinding to msg 3 or 4 finds msg 5's `B`, so the plugin writes
|
|
189
|
+
`B`; the "big" state they began from falls inside the gap and can never be restored.
|
|
190
|
+
And if the file is currently over the cap, **no** target touches it: it prefers to do
|
|
191
|
+
nothing rather than write back an older backup.
|
|
192
|
+
|
|
193
|
+
## 4. Why these limitations are accepted
|
|
194
|
+
|
|
195
|
+
Removing **limitation 1** would require the plugin to know which files an arbitrary
|
|
196
|
+
command or external edit modified. Neither route works: having every external writer
|
|
197
|
+
declare itself (neither `sed`, nor build scripts, nor editors will), or tracking the
|
|
198
|
+
whole workspace and taking a whole-tree snapshot before every change — enormous in
|
|
199
|
+
volume, and precise workspace-level history is exactly the job of dedicated snapshot
|
|
200
|
+
tools such as Git. The plugin only does lightweight rewinding aimed at Agent tooling,
|
|
201
|
+
so this limitation stays.
|
|
202
|
+
|
|
203
|
+
Removing **limitation 2** would mean giving up "unchanged, not stored" and "over the
|
|
204
|
+
cap, not stored": every message boundary would have to record **every** tracked file
|
|
205
|
+
(changed or not), and large files would be backed up in full again and again. That
|
|
206
|
+
cost has two layers: storage grows linearly with the conversation, and messages that
|
|
207
|
+
change no file are the overwhelming majority; and every backup reads and writes a
|
|
208
|
+
whole file, so a very large one saturates disk IO (which is exactly why the per-file
|
|
209
|
+
cap exists).
|
|
210
|
+
|
|
211
|
+
What the two limitations buy is: lightweight, predictable, and **only ever writing
|
|
212
|
+
back content it actually backed up, never a guess**. For precise workspace-level
|
|
213
|
+
history, use Git.
|
|
214
|
+
|
|
215
|
+
## Background
|
|
216
|
+
|
|
217
|
+
This document was originally written for issue
|
|
218
|
+
[#5](https://github.com/SiriLee/dsh-rewind/issues/5) and completed for issue
|
|
219
|
+
[#39](https://github.com/SiriLee/dsh-rewind/issues/39). See also:
|
|
220
|
+
[SECURITY.md](../../SECURITY.md) · [Snapshot auto-cleanup](../snapshot-auto-cleanup.md)
|
|
@@ -1,55 +1,131 @@
|
|
|
1
1
|
# 文件回退的追踪边界
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 回退文件时,插件**只写回它确实备份过的内容**:被写类工具改动过的文件始终可以精确还原;而某一段状态**没有被备份**时,还原就会**不充分**——它宁可不动,也不猜测。
|
|
4
|
+
>
|
|
5
|
+
> 本文说明它的原理、由此产生的两项已知限制,以及为什么接受它们。这些都是**有意取舍**(与 Claude Code 的检查点语义一致),不是缺陷。
|
|
4
6
|
|
|
5
|
-
##
|
|
7
|
+
## 一、文件变更的两种模式
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
文件在 DSH 中只有两种变化途径,插件对二者的可观测性完全不同。
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
**模型调用写类工具**(`write`、`edit`):调用参数给出了确切的文件路径,插件能在文件被覆盖**之前**捕获它的当前内容。这条路径是确定性的——一经处理,这个文件就一定可以还原。
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
**外部编辑**(命令行 Bash、PowerShell 写入、构建脚本改写,或手动在编辑器里保存):没有任何环节向插件声明被修改的路径,**不存在可以提前捕获的对象**,只能事后发现。
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## 二、原理
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
### 一份备份 = 一条消息开始时的状态
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
插件不保存"工作区当前状态",保存的是**状态转变**。每份备份只回答一个问题:
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
> **这条消息开始时,这个文件是什么?**
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
2. **文件在模型第一次编辑工具改动前就被手动编辑过。** 插件的首个快照是在编辑工具调用时拍的,因此回退只能把文件还原到手动编辑**之后**的状态,而非**之前**。
|
|
23
|
+
内容有两种形态:`null` 表示文件**不存在**(还原时表现为删除),另一种则是完整副本。这些备份合起来就是这个会话的**快照**(按消息分组存放)。备份归属于**触发它的那条用户消息**;同一条消息的回合里,每次写类工具调用都会留下一份备份,还原时取其中**最早**的一份。
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
### 备份从哪里来:追踪列表
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
插件不扫描整个工作区,只认一张**追踪列表**:文件被写类工具处理**一次**即加入;此后每条新的用户消息到达时,只复查名单里的文件;不在名单里的文件没有任何备份。
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
备份有两个来源:
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
- **写前备份**:模型调用写类工具时,在改动落盘**之前**取当前内容,记在**触发这一轮的那条用户消息**名下;
|
|
32
|
+
- **消息边界复查**:新消息到达时,若名单里某个文件当前的磁盘内容与**最近一份备份**不一致,就把当前内容记为**这条消息**的备份。这是外部编辑唯一的入口。
|
|
31
33
|
|
|
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
|
-
```
|
|
34
|
+
### 两种不产生备份的情形
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
- **不变不存**:边界复查发现磁盘与最近一份备份一致时,这次复查不写备份;若本回合稍后写类工具改动了该文件,那份写前备份照常记录。所以"没有改动文件的消息"自己不产生备份。
|
|
37
|
+
- **超量不存**:准备写备份之前若发现单个文件超过体积上限(默认 8 MiB,可用 `DSH_REWIND_MAX_FILE_BYTES` 调整,设为 `0` 关闭),插件**拒绝备份**;这个文件在回到限制以下之前不会产生新的备份(旧备份仍在)。
|
|
41
38
|
|
|
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
|
-
```
|
|
39
|
+
### 还原:只依据备份
|
|
50
40
|
|
|
51
|
-
|
|
41
|
+
还原不读工作区的其他内容,也不做推断:
|
|
52
42
|
|
|
53
|
-
|
|
43
|
+
1. **找备份**:对每个文件,取**目标消息及其之后最早**的一份备份;目标消息自己没有备份时,就向更晚的消息找最早的那一份。
|
|
44
|
+
2. **与磁盘比对**:一致 → 不做任何操作;不一致 → 写回备份的内容(`null` → 删除该文件)。
|
|
54
45
|
|
|
55
|
-
|
|
46
|
+
向更晚的消息找,是因为"不变不存"意味着目标消息开始时的状态与最近一份备份相同——更晚消息的最早备份,记的正是那一刻的状态(前提是目标消息之后、那份备份之前没有未被记录的改动,见限制二)。反过来,不取"目标消息之前最近"的那份备份,是因为它可能早于一个**缺口**(限制二·表现 3):写回它等于用更旧的版本覆盖当前文件。**宁可少还原,也不写错内容。**
|
|
47
|
+
|
|
48
|
+
### 示例
|
|
49
|
+
|
|
50
|
+
| 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
|
|
51
|
+
| :---: | --- | :---: | --- |
|
|
52
|
+
| 1 | `write` 新建 | `—` → `A` | `null`(落盘前文件不存在,写前备份并加入追踪) |
|
|
53
|
+
| 2 | — | `A` | `A`(磁盘与上一份备份 `null` 不一致) |
|
|
54
|
+
| 3 | — | `A` | (无:与最近一份备份一致,且本轮未编辑,不变不存) |
|
|
55
|
+
| 4 | `edit` | `A` → `B` | `A`(本次编辑之前的内容,写前备份) |
|
|
56
|
+
| 5 | `bash` 改写 | `B` → `C` | `B`(消息 5 到达时磁盘已是 `B`,消息 4 的编辑已生效) |
|
|
57
|
+
|
|
58
|
+
磁盘当前是 `C`。回退结果:目标 1 → 备份为 `null`,删除文件;目标 2 → `A`;目标 3 → 自己没有备份,向更晚的消息找到消息 4 的 `A`,正是消息 3 开始时的状态;目标 4 → `A`;目标 5 → `B`。
|
|
59
|
+
|
|
60
|
+
## 三、已知限制
|
|
61
|
+
|
|
62
|
+
两项限制的根因相同:**某一段状态没有被备份**。限制一的结果是**无法还原**,限制二的结果是**还原不充分**。
|
|
63
|
+
|
|
64
|
+
### 限制一:没有进入追踪的历史 —— 无法还原
|
|
65
|
+
|
|
66
|
+
**"被写类工具处理过"是文件加入追踪列表、产生备份的前提。** 在此之前,它经历的所有外部编辑都没有备份,两个来源也都不会为它写备份。两种情形:
|
|
67
|
+
|
|
68
|
+
**整个会话都没有处理过它。** 回退到任何消息,插件对该文件都只能报告"没有可还原的改动"。
|
|
69
|
+
|
|
70
|
+
**后来处理过它(更常见)。** 从那一刻起才有备份,更早的历史无法还原——下面例子里 `v0` 是插件从未见过的历史:
|
|
71
|
+
|
|
72
|
+
| 消息 | 回合内发生 | 文件内容 | 备份:这条消息开始时是什么 |
|
|
73
|
+
| :---: | --- | :---: | --- |
|
|
74
|
+
| 1 | — | `v0` | (无:文件还没有加入追踪列表) |
|
|
75
|
+
| 2 | 手动编辑 | `v0` → `v1` | (无:同上) |
|
|
76
|
+
| 3 | 手动编辑 | `v1` → `v2` | (无:同上) |
|
|
77
|
+
| 4 | `edit` 首次处理 | `v2` → `v3` | `v2`(写前备份:这一刻文件才加入追踪列表) |
|
|
78
|
+
| 5 | — | `v3` | `v3`(磁盘与上一份备份 `v2` 不一致) |
|
|
79
|
+
|
|
80
|
+
回退到消息 5 → `v3`;消息 4 → `v2`;而**回退到消息 1、2、3 也都会得到 `v2`**——它们之后最早的备份就是消息 4 的 `v2`。`v2` 确实是"首次处理那一刻"的内容,但这三条消息开始时的真实内容分别是 `v0`、`v0`、`v1`,插件从未见过,无法还原。
|
|
81
|
+
|
|
82
|
+
### 限制二:有一段状态没有被备份 —— 还原不充分
|
|
83
|
+
|
|
84
|
+
"不变不存"与"超量不存"都会让某一段状态没有备份。目标消息自己没有备份时,插件按"向更晚的消息找"取备份;一旦目标之后、那份备份之前发生过**未被记录的改动**,取到的就不是目标消息开始时的状态。三种表现依次叠加更多场景。
|
|
85
|
+
|
|
86
|
+
**表现 1:变更还没有归属到任何消息**
|
|
87
|
+
|
|
88
|
+
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
89
|
+
| :---: | --- | :---: | --- |
|
|
90
|
+
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
91
|
+
| 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
|
|
92
|
+
| 3 | — | `A` | (无:与上一份备份一致,且本轮未编辑) |
|
|
93
|
+
| (之后) | 手动修改文件 | `A` → `X` | (无:还没有新消息来认领它) |
|
|
94
|
+
|
|
95
|
+
回退到消息 3:它自己及其之后都没有备份,插件不知道文件变过,于是**不做任何操作,文件停在 `X`**(消息 3 开始时其实是 `A`)。要取回 `A`,回退到消息 2——它名下的备份就是 `A`。之后再来的消息只会让**当时**的状态(`X`)变得可回退,消息 3 开始时的 `A` 仍然回不来。
|
|
96
|
+
|
|
97
|
+
**表现 2:记下的是变更之后的状态**
|
|
98
|
+
|
|
99
|
+
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
100
|
+
| :---: | --- | :---: | --- |
|
|
101
|
+
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
102
|
+
| 2 | — | `A` | `A`(与上一份备份 `null` 不一致) |
|
|
103
|
+
| 3 | — | `A` | (无:与最近一份备份一致) |
|
|
104
|
+
| 4 | 命令把文件改成 `B` | `A` → `B` | (无:消息 4 到达时磁盘还是 `A`) |
|
|
105
|
+
| 5 | — | `B` | `B`(与上一份备份 `A` 不一致) |
|
|
106
|
+
|
|
107
|
+
回退到消息 3 或 4:它们自己没有备份,向更晚的消息找到消息 5 的 `B`——那是变更**之后**的状态,而它们开始时是 `A`。此时磁盘与备份一致,插件不做任何操作,文件停在 `B`;若此后文件又变过,插件会把文件写成 `B`(覆盖比它更新的内容);若消息 4 的回合里是命令**删除**文件,消息 5 的备份就是 `null`,回退到 3 或 4 会**删除当前文件**。与表现 1 的差别在于:这次变更之后还有消息,它把变更后的状态记了下来——表现 1 里则一条都没记。
|
|
108
|
+
|
|
109
|
+
**表现 3:变更期间文件超过上限**
|
|
110
|
+
|
|
111
|
+
| 消息 | 回合内发生 | 文件内容 | 备份 |
|
|
112
|
+
| :---: | --- | :---: | --- |
|
|
113
|
+
| 1 | `write` 新建 | `—` → `A` | `null` |
|
|
114
|
+
| 2 | 文件扩充 | `A` → `G1` | `A`(与上一份备份 `null` 不一致) |
|
|
115
|
+
| 3 | 写类工具或外部修改 | `G1` → `G2` | (无:超过上限,不备份) |
|
|
116
|
+
| 4 | 缩回 `B` | `G2` → `B` | (无:消息到达时仍超限,跳过复查) |
|
|
117
|
+
| 5 | `edit` | `B` → `X` | `B`(写前备份,重新追踪) |
|
|
118
|
+
|
|
119
|
+
"小 → 大 → 小"的场景中,超过上限的那一段没有任何备份,是一个**缺口**。回退到消息 3、4 时取到的是消息 5 的 `B`——插件会把文件写成 `B`;它们开始时的"大"状态落在缺口里,永远无法还原。若磁盘当前仍然超过上限,**任何**目标都不会动这个文件:宁可不动,也不写回一份更旧的备份。
|
|
120
|
+
|
|
121
|
+
## 四、为什么接受这些限制
|
|
122
|
+
|
|
123
|
+
**限制一**要消除,得让插件知道任意命令、外部编辑动了哪些文件。两条路都不通:一是让所有外部写入主动申报——`sed`、构建脚本、编辑器都不会这么做;二是全量追踪整个工作区、每次改动前做整树快照——体量极大,而工作区级的精确历史正是 Git 这类专业快照工具的职责。插件只做面向 Agent 工具的轻量回退,因此保留这条限制。
|
|
124
|
+
|
|
125
|
+
**限制二**要消除,必须放弃"不变不存"与"超量不存":每个消息边界都要记录**每个**被追踪文件(即使一点没变),大文件也会被反复完整备份。代价有两层——存储量随对话长度线性增长,而未改动文件的消息占绝大多数;以及每次备份都要完整读写文件,文件很大时会把磁盘 IO 打满(单文件上限正是为此而设)。
|
|
126
|
+
|
|
127
|
+
两项限制换来的是:轻量、可预测,以及**只写它确实备份过的内容、绝不做任何猜测**。需要工作区级别的精确历史时,请使用 Git。
|
|
128
|
+
|
|
129
|
+
## 背景
|
|
130
|
+
|
|
131
|
+
本文最初为 issue [#5](https://github.com/SiriLee/dsh-rewind/issues/5) 编写,并为 issue [#39](https://github.com/SiriLee/dsh-rewind/issues/39) 进行完善。相关:[SECURITY.md](../../SECURITY.md) · [快照自动清理](../snapshot-auto-cleanup.zh.md)
|
package/lib/client.js
CHANGED
|
@@ -249,9 +249,9 @@ var CLASS = {
|
|
|
249
249
|
guardHint: "dsh-rewind-guard-hint"
|
|
250
250
|
};
|
|
251
251
|
var REWIND_ICON_SVG = [
|
|
252
|
-
'<svg width="
|
|
253
|
-
' <path d="M6.5 2.5 2.5 6.5l4 4" stroke="currentColor" stroke-width="1.
|
|
254
|
-
' <path d="M2.5 6.5h7a4 4 0 0 1 4 4v1.5" stroke="currentColor" stroke-width="1.
|
|
252
|
+
'<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">',
|
|
253
|
+
' <path d="M6.5 2.5 2.5 6.5l4 4" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
254
|
+
' <path d="M2.5 6.5h7a4 4 0 0 1 4 4v1.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/>',
|
|
255
255
|
"</svg>"
|
|
256
256
|
].join("");
|
|
257
257
|
var STYLE = `
|
|
@@ -263,7 +263,7 @@ var STYLE = `
|
|
|
263
263
|
height: 28px;
|
|
264
264
|
padding: 6px;
|
|
265
265
|
border: none;
|
|
266
|
-
border-radius:
|
|
266
|
+
border-radius: var(--dsw-radius-sm, 8px);
|
|
267
267
|
background: transparent;
|
|
268
268
|
color: var(--dsw-alias-label-tertiary);
|
|
269
269
|
cursor: pointer;
|
|
@@ -1035,8 +1035,9 @@ function collectDurableTargets(snapshot, chat, hiddenSeqs) {
|
|
|
1035
1035
|
return collectTargets(chat, hiddenSeqs);
|
|
1036
1036
|
}
|
|
1037
1037
|
function inboxOf(session) {
|
|
1038
|
-
return session.projections.faceOf(
|
|
1038
|
+
return session.projections.faceOf(INBOX_PROJECTION).getSnapshot();
|
|
1039
1039
|
}
|
|
1040
|
+
var INBOX_PROJECTION = "inbox";
|
|
1040
1041
|
function bubbleTextOf(row) {
|
|
1041
1042
|
const clone = row.cloneNode(true);
|
|
1042
1043
|
clone.lastElementChild?.remove();
|
|
@@ -1088,9 +1089,12 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
|
|
|
1088
1089
|
(0, import_react.useLayoutEffect)(() => {
|
|
1089
1090
|
let active = true;
|
|
1090
1091
|
let queued = false;
|
|
1092
|
+
let unsubscribeInbox;
|
|
1093
|
+
let inboxOwner;
|
|
1091
1094
|
const refresh = () => {
|
|
1092
1095
|
if (!active) return;
|
|
1093
1096
|
const session = sessionOf(sessionId);
|
|
1097
|
+
syncInboxSubscription(session);
|
|
1094
1098
|
if (session === void 0) {
|
|
1095
1099
|
setTargets([]);
|
|
1096
1100
|
return;
|
|
@@ -1116,12 +1120,27 @@ function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchC
|
|
|
1116
1120
|
refresh();
|
|
1117
1121
|
});
|
|
1118
1122
|
};
|
|
1123
|
+
const syncInboxSubscription = (session) => {
|
|
1124
|
+
if (session === inboxOwner) return;
|
|
1125
|
+
unsubscribeInbox?.();
|
|
1126
|
+
unsubscribeInbox = void 0;
|
|
1127
|
+
inboxOwner = session;
|
|
1128
|
+
if (session === void 0) return;
|
|
1129
|
+
try {
|
|
1130
|
+
unsubscribeInbox = session.projections.faceOf(INBOX_PROJECTION).subscribe(queueRefresh);
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
rewindLog.warn("portals", "inbox projection subscribe failed", error);
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1119
1135
|
refresh();
|
|
1120
1136
|
const observer = new MutationObserver(queueRefresh);
|
|
1121
1137
|
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["style"] });
|
|
1122
1138
|
return () => {
|
|
1123
1139
|
active = false;
|
|
1124
1140
|
observer.disconnect();
|
|
1141
|
+
unsubscribeInbox?.();
|
|
1142
|
+
unsubscribeInbox = void 0;
|
|
1143
|
+
inboxOwner = void 0;
|
|
1125
1144
|
};
|
|
1126
1145
|
}, [sessionId, sessionOf]);
|
|
1127
1146
|
return targets.map((target) => (0, import_react_dom.createPortal)(
|
|
@@ -1252,9 +1271,9 @@ function createRewindBridge(deps) {
|
|
|
1252
1271
|
}
|
|
1253
1272
|
|
|
1254
1273
|
// src/client/build-info.ts
|
|
1255
|
-
var PLUGIN_VERSION = true ? "0.14.0
|
|
1274
|
+
var PLUGIN_VERSION = true ? "0.14.0" : "dev";
|
|
1256
1275
|
var PLUGIN_PACKAGE = "dsh-rewind-plugin";
|
|
1257
|
-
var BUILD_HASH = true ? "
|
|
1276
|
+
var BUILD_HASH = true ? "09b74c90" : "dev";
|
|
1258
1277
|
|
|
1259
1278
|
// src/client/locales.ts
|
|
1260
1279
|
var zh = {
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,9 @@ var en = {
|
|
|
20
20
|
"plan.restore": "restore {path}",
|
|
21
21
|
"plan.delete": "delete {path}",
|
|
22
22
|
"plan.noChanges": "No restorable changes after the target.",
|
|
23
|
+
"plan.skip": " not restored: {path} ({reason})",
|
|
24
|
+
"skipReason.oversized": "over the {limit} backup limit, so the recorded state predates untracked edits",
|
|
25
|
+
"uncovered.header": "{count} tracked file(s) exceed the {limit} backup limit and are not covered:",
|
|
23
26
|
"error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
|
|
24
27
|
"failures.suffix": "; {count} file(s) failed to restore: {list}",
|
|
25
28
|
"failures.item": "{path} ({message})",
|
|
@@ -35,7 +38,8 @@ var en = {
|
|
|
35
38
|
"success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
|
|
36
39
|
"noUserMessages": "This session has no rewindable user messages yet.",
|
|
37
40
|
"chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
|
|
38
|
-
"command.description": "Rewind the conversation
|
|
41
|
+
"command.description": "Rewind the conversation and/or code to a previous point",
|
|
42
|
+
"command.undoDescription": "Alias of /rewind",
|
|
39
43
|
"cleanup.description": "Manage automatic cleanup of session snapshot backups",
|
|
40
44
|
"cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
|
|
41
45
|
"cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s).",
|
|
@@ -69,6 +73,9 @@ var zh = {
|
|
|
69
73
|
"plan.restore": "\u8FD8\u539F {path}",
|
|
70
74
|
"plan.delete": "\u5220\u9664 {path}",
|
|
71
75
|
"plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
|
|
76
|
+
"plan.skip": " \u672A\u8FD8\u539F\uFF1A{path}\uFF08{reason}\uFF09",
|
|
77
|
+
"skipReason.oversized": "\u5DF2\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u8BB0\u5F55\u7684\u72B6\u6001\u65E9\u4E8E\u672A\u88AB\u8DDF\u8E2A\u7684\u4FEE\u6539",
|
|
78
|
+
"uncovered.header": "{count} \u4E2A\u5DF2\u8DDF\u8E2A\u6587\u4EF6\u8D85\u8FC7 {limit} \u7684\u5907\u4EFD\u4E0A\u9650\uFF0C\u672A\u7EB3\u5165\u8BB0\u5F55\uFF1A",
|
|
72
79
|
"error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
|
|
73
80
|
"failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
|
|
74
81
|
"failures.item": "{path}\uFF08{message}\uFF09",
|
|
@@ -84,7 +91,8 @@ var zh = {
|
|
|
84
91
|
"success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
|
|
85
92
|
"noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
86
93
|
"chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
|
|
87
|
-
"command.description": "\
|
|
94
|
+
"command.description": "\u56DE\u9000\u5BF9\u8BDD\u548C/\u6216\u4EE3\u7801\u5230\u4E4B\u524D\u7684\u67D0\u6761\u6D88\u606F",
|
|
95
|
+
"command.undoDescription": "/rewind \u7684\u522B\u540D",
|
|
88
96
|
"cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
|
|
89
97
|
"cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
|
|
90
98
|
"cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002",
|
|
@@ -237,6 +245,14 @@ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
|
237
245
|
var COMPARE_CHUNK_BYTES = 64 * 1024;
|
|
238
246
|
var REPLACEMENT_CHAR = "\uFFFD";
|
|
239
247
|
var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
248
|
+
var MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
|
|
249
|
+
var DEFAULT_MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
250
|
+
function maxFileBytesFromEnv() {
|
|
251
|
+
const raw = process.env[MAX_FILE_BYTES_ENV]?.trim();
|
|
252
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
253
|
+
const parsed = Number(raw);
|
|
254
|
+
return Number.isInteger(parsed) ? parsed : void 0;
|
|
255
|
+
}
|
|
240
256
|
var MAX_ANCHOR_GROUPS = 100;
|
|
241
257
|
var CURRENT_STORE_VERSION = 2;
|
|
242
258
|
var UnknownStoreVersionError = class extends Error {
|
|
@@ -323,6 +339,14 @@ var defaultProbe = {
|
|
|
323
339
|
return { kind: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
324
340
|
}
|
|
325
341
|
},
|
|
342
|
+
async size(path) {
|
|
343
|
+
try {
|
|
344
|
+
const st = await stat(path);
|
|
345
|
+
return st.isFile() ? st.size : void 0;
|
|
346
|
+
} catch {
|
|
347
|
+
return void 0;
|
|
348
|
+
}
|
|
349
|
+
},
|
|
326
350
|
isLink: isLinkPath
|
|
327
351
|
};
|
|
328
352
|
function safeFileId(callId) {
|
|
@@ -652,6 +676,11 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
652
676
|
lastEntryTime = 0;
|
|
653
677
|
/** Store options; `dedup` toggles in-place content dedup (default on). */
|
|
654
678
|
dedup;
|
|
679
|
+
/**
|
|
680
|
+
* Per-file backup cap in bytes; `<= 0` means no cap. Capture paths consult it
|
|
681
|
+
* and the restore planner refuses a pre-cap record over an over-cap file.
|
|
682
|
+
*/
|
|
683
|
+
maxFileBytes;
|
|
655
684
|
/** Resolved checkpoint store root (absolute); see the constructor's fallback. */
|
|
656
685
|
root;
|
|
657
686
|
/**
|
|
@@ -677,12 +706,25 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
677
706
|
formatVersion = null;
|
|
678
707
|
constructor(root, opts) {
|
|
679
708
|
this.dedup = opts?.dedup ?? true;
|
|
709
|
+
this.maxFileBytes = opts?.maxFileBytes ?? maxFileBytesFromEnv() ?? DEFAULT_MAX_FILE_BYTES;
|
|
680
710
|
this.root = root ?? process.env[SNAPSHOT_ROOT_ENV] ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
|
|
681
711
|
}
|
|
682
712
|
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
683
713
|
sessionDir(sessionId) {
|
|
684
714
|
return join(this.root, safeSessionId(sessionId));
|
|
685
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* True when `size` bytes is over the per-file cap, so it must not be backed
|
|
718
|
+
* up. `undefined` (a backend that cannot report a size) answers false: the
|
|
719
|
+
* guard fails toward STORING, never toward calling a file too big on a guess.
|
|
720
|
+
*/
|
|
721
|
+
isOverFileCap(size) {
|
|
722
|
+
return this.maxFileBytes > 0 && size !== void 0 && size > this.maxFileBytes;
|
|
723
|
+
}
|
|
724
|
+
/** The cap in force, for reporting (0 = unlimited). */
|
|
725
|
+
get fileCapBytes() {
|
|
726
|
+
return this.maxFileBytes;
|
|
727
|
+
}
|
|
686
728
|
/** Absolute path of one anchor group directory. */
|
|
687
729
|
anchorDir(sessionId, anchorSeq) {
|
|
688
730
|
return join(this.sessionDir(sessionId), String(anchorSeq));
|
|
@@ -1035,6 +1077,10 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1035
1077
|
skipped.push(entry.path);
|
|
1036
1078
|
continue;
|
|
1037
1079
|
}
|
|
1080
|
+
if (probe.size !== void 0 && this.isOverFileCap(await probe.size(entry.path))) {
|
|
1081
|
+
skipped.push(entry.path);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1038
1084
|
if (source !== null && source.kind === "lossyText") {
|
|
1039
1085
|
const same2 = await probe.matches(source, entry.path);
|
|
1040
1086
|
if (same2 !== true) skipped.push(entry.path);
|
|
@@ -1888,6 +1934,21 @@ var SnapshotStore = class _SnapshotStore {
|
|
|
1888
1934
|
}
|
|
1889
1935
|
return paths;
|
|
1890
1936
|
}
|
|
1937
|
+
/**
|
|
1938
|
+
* The session's recorded paths whose current size is over the cap, so no
|
|
1939
|
+
* backup of that state exists. One `stat` per recorded path; a path first seen
|
|
1940
|
+
* over the cap was never recorded and is therefore not listed.
|
|
1941
|
+
*/
|
|
1942
|
+
async uncoveredPaths(sessionId, probe = defaultProbe) {
|
|
1943
|
+
const sizeOf = probe.size?.bind(probe);
|
|
1944
|
+
if (this.maxFileBytes <= 0 || sizeOf === void 0) return [];
|
|
1945
|
+
const uncovered = [];
|
|
1946
|
+
for (const path of await this.trackedPaths(sessionId)) {
|
|
1947
|
+
const size = await sizeOf(path);
|
|
1948
|
+
if (this.isOverFileCap(size)) uncovered.push({ path, ...size !== void 0 ? { size } : {} });
|
|
1949
|
+
}
|
|
1950
|
+
return uncovered;
|
|
1951
|
+
}
|
|
1891
1952
|
/**
|
|
1892
1953
|
* Summarize a session's on-disk footprint for a clear dry-run: anchor-group
|
|
1893
1954
|
* count, committed checkpoint-entry count (one per `.json` in an anchor
|
|
@@ -2105,6 +2166,7 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
|
|
|
2105
2166
|
for (const path of tracked) {
|
|
2106
2167
|
try {
|
|
2107
2168
|
if (await probe.isLink(path)) continue;
|
|
2169
|
+
if (store.isOverFileCap(await probe.size?.(path))) continue;
|
|
2108
2170
|
const last = await store.lastKnownContent(sessionId, path);
|
|
2109
2171
|
if (last !== void 0 && last !== null && last.kind !== "lossyText") {
|
|
2110
2172
|
const same = await probe.matches(last, path);
|
|
@@ -2352,6 +2414,7 @@ async function captureBefore(fs, store, exec, pending) {
|
|
|
2352
2414
|
});
|
|
2353
2415
|
if (session === void 0) return;
|
|
2354
2416
|
if (info !== void 0 && info.type !== "file") return;
|
|
2417
|
+
if (info !== void 0 && store.isOverFileCap(info.size)) return;
|
|
2355
2418
|
const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
|
|
2356
2419
|
if (info === void 0) {
|
|
2357
2420
|
pending.set(key, { path: target.displayPath, backup: null });
|
|
@@ -2415,7 +2478,12 @@ function buildMarker() {
|
|
|
2415
2478
|
function describeTarget(target) {
|
|
2416
2479
|
return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
|
|
2417
2480
|
}
|
|
2418
|
-
function
|
|
2481
|
+
function formatBytes(bytes) {
|
|
2482
|
+
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MiB`;
|
|
2483
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
|
|
2484
|
+
return `${bytes} B`;
|
|
2485
|
+
}
|
|
2486
|
+
function formatPlan(plan, files, uncovered = [], maxFileBytes = 0) {
|
|
2419
2487
|
const lines = [
|
|
2420
2488
|
t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
|
|
2421
2489
|
];
|
|
@@ -2427,6 +2495,11 @@ function formatPlan(plan, files) {
|
|
|
2427
2495
|
} else {
|
|
2428
2496
|
lines.push(t("plan.noChanges"));
|
|
2429
2497
|
}
|
|
2498
|
+
if (uncovered.length > 0) {
|
|
2499
|
+
lines.push(t("uncovered.header", { count: uncovered.length, limit: formatBytes(maxFileBytes) }));
|
|
2500
|
+
for (const file of uncovered.slice(0, 5)) lines.push(` ${file.path}`);
|
|
2501
|
+
if (uncovered.length > 5) lines.push(" \u2026");
|
|
2502
|
+
}
|
|
2430
2503
|
lines.push(`impact=${files.length}`);
|
|
2431
2504
|
for (const file of files) {
|
|
2432
2505
|
lines.push(`${file.action}:${file.path}`);
|
|
@@ -2447,6 +2520,11 @@ function renderFailures(failed) {
|
|
|
2447
2520
|
list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
|
|
2448
2521
|
});
|
|
2449
2522
|
}
|
|
2523
|
+
function renderSkipped(outcome, uncovered, maxFileBytes) {
|
|
2524
|
+
const capped = new Set(uncovered.map((item) => item.path));
|
|
2525
|
+
const limit = formatBytes(maxFileBytes);
|
|
2526
|
+
return outcome.skipped.filter((path) => capped.has(path)).map((path) => t("plan.skip", { path, reason: t("skipReason.oversized", { limit }) })).join("\n");
|
|
2527
|
+
}
|
|
2450
2528
|
async function resolveObservationTarget(fs, path) {
|
|
2451
2529
|
try {
|
|
2452
2530
|
return await fs.resolve(path);
|
|
@@ -2549,7 +2627,12 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
2549
2627
|
const parts = [];
|
|
2550
2628
|
if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
|
|
2551
2629
|
if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
|
|
2552
|
-
if (outcome.skipped.length > 0)
|
|
2630
|
+
if (outcome.skipped.length > 0) {
|
|
2631
|
+
parts.push(t("skip.count", { count: outcome.skipped.length }));
|
|
2632
|
+
const capped = await store.uncoveredPaths(agent.session.id).catch(() => []);
|
|
2633
|
+
const skippedLines = renderSkipped(outcome, capped, store.fileCapBytes);
|
|
2634
|
+
if (skippedLines !== "") parts.push(skippedLines);
|
|
2635
|
+
}
|
|
2553
2636
|
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
|
|
2554
2637
|
restore += renderFailures(outcome.failed);
|
|
2555
2638
|
}
|
|
@@ -2602,7 +2685,8 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
|
|
|
2602
2685
|
if (impacts === void 0) {
|
|
2603
2686
|
return { kind: "error", text: t("storeUnsupported", { version: await store.readStoreVersion(session.id) ?? 0 }) };
|
|
2604
2687
|
}
|
|
2605
|
-
|
|
2688
|
+
const uncovered = await store.uncoveredPaths(session.id).catch(() => []);
|
|
2689
|
+
return { kind: "success", text: formatPlan(plan, impacts, uncovered, store.fileCapBytes) };
|
|
2606
2690
|
}
|
|
2607
2691
|
if (parts[0] === "__candidates") {
|
|
2608
2692
|
const candidates = listRewindCandidates(session.snapshotEvents(), session.surface.nodes);
|
|
@@ -2805,7 +2889,7 @@ function apply(ctx, config) {
|
|
|
2805
2889
|
});
|
|
2806
2890
|
yield ctx.commands.register({
|
|
2807
2891
|
name: "undo",
|
|
2808
|
-
description: t("command.
|
|
2892
|
+
description: t("command.undoDescription"),
|
|
2809
2893
|
handler: rewindHandler
|
|
2810
2894
|
});
|
|
2811
2895
|
yield ctx.commands.register({
|
|
@@ -284,6 +284,10 @@ interface RewindPortalsProps extends RewindBridgeDeps {
|
|
|
284
284
|
* (one pass per mutation batch via queueMicrotask) and diffed (setState is
|
|
285
285
|
* skipped when the target set is unchanged), so the plugin never runs a
|
|
286
286
|
* synchronous full-transcript scan inside a commit microtask.
|
|
287
|
+
*
|
|
288
|
+
* The scan is driven by both channels its targets are made of: the DOM (rows and
|
|
289
|
+
* their actions containers) and the session's `inbox` projection (the pending
|
|
290
|
+
* occurrences — see `syncInboxSubscription`).
|
|
287
291
|
*/
|
|
288
292
|
export declare function RewindPortals({ sessionId, sessionOf, chatOf, isMainViewSession, watchChat, t, subscribeLocale, setComposerText }: RewindPortalsProps): ReactNode;
|
|
289
293
|
/** The current composer draft: the contenteditable `textContent`.
|
|
@@ -20,7 +20,15 @@ export declare const CLASS: {
|
|
|
20
20
|
readonly popoverGhost: 'dsh-rewind-popover-ghost';
|
|
21
21
|
readonly guardHint: 'dsh-rewind-guard-hint';
|
|
22
22
|
};
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* The ↶ glyph, drawn inline so the bundle stays dependency-free.
|
|
25
|
+
*
|
|
26
|
+
* Size follows the 0.1.7 product-icon set (15px, as the neighbouring copy/branch
|
|
27
|
+
* actions in `MessageIconActions`). Stroke is 1.25 rather than upstream's
|
|
28
|
+
* `Regular` 1: ↶ is a single open stroke next to a dense outline-plus-fill copy
|
|
29
|
+
* glyph, so at 1 it reads thinned out. Rounded caps/joins stay — upstream keeps
|
|
30
|
+
* them on curved artwork.
|
|
31
|
+
*/
|
|
24
32
|
export declare const REWIND_ICON_SVG: string;
|
|
25
33
|
/** One injected stylesheet (scoped under `.dsh-rewind-*`). */
|
|
26
|
-
export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius:
|
|
34
|
+
export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: var(--dsw-radius-sm, 8px);\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 12px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n/* Arrow navigation moves real focus, so the focused row carries the pointer's\n fill and the browser ring is suppressed: in dark mode the UA two-tone ring\n draws its dark half against the filled row and reads as a residue edge (the\n harness Menu suppresses it the same way). */\n.dsh-rewind-popover-option:focus-visible:not(:disabled) {\n background: var(--dsw-alias-interactive-bg-hover);\n outline: none;\n}\n.dsh-rewind-popover-option:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-button-primary-fill);\n color: var(--dsw-alias-label-primary-foreground);\n}\n.dsh-rewind-popover-primary:hover:not(:disabled) {\n background: var(--dsw-alias-button-primary-hover);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n.dsh-rewind-guard-hint {\n position: fixed;\n z-index: 1000;\n max-width: min(440px, calc(100vw - 24px));\n padding: 8px 12px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: 10px;\n background: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-3));\n box-shadow: var(--dsw-shadow-lv3);\n font-size: 13px;\n line-height: 18px;\n color: var(--dsw-alias-label-primary);\n pointer-events: none;\n}\n\n/* ---- Snapshot-cleanup configuration form ---- */\n/* The Plugins page draws the bundle's title, icon, crumb, description, and the\n shared settings form frame (labels, save, read-only and failure lines); this\n styles only the one control the card owns: the auto-cleanup toggle row. */\n.dsh-rewind-cleanup-permission {\n display: grid;\n gap: 6px;\n padding: 12px 0;\n}\n.dsh-rewind-cleanup-toggle-row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n font-size: 13px;\n line-height: 1.5;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-cleanup-toggle-label {\n flex: 1;\n min-width: 0;\n}\n.dsh-rewind-cleanup-hint {\n margin: 0;\n font-size: 12px;\n line-height: 1.5;\n color: var(--dsw-alias-label-tertiary);\n}\n";
|
package/lib/types/locales.d.ts
CHANGED
|
@@ -56,6 +56,9 @@ export declare const en: {
|
|
|
56
56
|
'plan.restore': string;
|
|
57
57
|
'plan.delete': string;
|
|
58
58
|
'plan.noChanges': string;
|
|
59
|
+
'plan.skip': string;
|
|
60
|
+
'skipReason.oversized': string;
|
|
61
|
+
'uncovered.header': string;
|
|
59
62
|
'error.invalidTarget': string;
|
|
60
63
|
'failures.suffix': string;
|
|
61
64
|
'failures.item': string;
|
|
@@ -72,6 +75,7 @@ export declare const en: {
|
|
|
72
75
|
noUserMessages: string;
|
|
73
76
|
chooseMode: string;
|
|
74
77
|
'command.description': string;
|
|
78
|
+
'command.undoDescription': string;
|
|
75
79
|
'cleanup.description': string;
|
|
76
80
|
'cleanup.inputHint': string;
|
|
77
81
|
'cleanup.status': string;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -67,6 +67,15 @@
|
|
|
67
67
|
*/
|
|
68
68
|
/** Environment variable overriding the store root (tests, exotic homes). */
|
|
69
69
|
export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
70
|
+
/** Env override for the per-file backup cap (`0` = unlimited); tests set it too. */
|
|
71
|
+
export declare const MAX_FILE_BYTES_ENV = "DSH_REWIND_MAX_FILE_BYTES";
|
|
72
|
+
/**
|
|
73
|
+
* A regular file larger than this is NOT backed up. This plugin is a
|
|
74
|
+
* lightweight, conversation-scoped undo, not a backup system: copying a very
|
|
75
|
+
* large file on every edit is what grows the store without bound and saturates
|
|
76
|
+
* IO (issue #39). The cap is a declared limitation, not a delta scheme.
|
|
77
|
+
*/
|
|
78
|
+
export declare const DEFAULT_MAX_FILE_BYTES: number;
|
|
70
79
|
/** Number of newest anchor groups (user messages) kept per session. */
|
|
71
80
|
export declare const MAX_ANCHOR_GROUPS = 100;
|
|
72
81
|
/**
|
|
@@ -208,6 +217,12 @@ export interface FileImpact {
|
|
|
208
217
|
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
209
218
|
readonly action: 'restore' | 'delete';
|
|
210
219
|
}
|
|
220
|
+
/** A recorded path whose current on-disk size is over the backup cap. */
|
|
221
|
+
export interface UncoveredPath {
|
|
222
|
+
readonly path: string;
|
|
223
|
+
/** Byte size observed on disk, when the probe could read one. */
|
|
224
|
+
readonly size?: number;
|
|
225
|
+
}
|
|
211
226
|
/** Outcome of one restore pass. */
|
|
212
227
|
export interface RestoreOutcome {
|
|
213
228
|
readonly restored: readonly string[];
|
|
@@ -352,6 +367,13 @@ export interface DiskProbe {
|
|
|
352
367
|
* rescue area) without loading it into memory.
|
|
353
368
|
*/
|
|
354
369
|
copy(path: string, dest: string): Promise<CopyOutcome>;
|
|
370
|
+
/**
|
|
371
|
+
* Byte size of the regular file at `path`, or `undefined` when absent or
|
|
372
|
+
* unreadable. Consulted before any content comparison so an over-cap file is
|
|
373
|
+
* skipped without being read. Optional; a missing accessor reads as "size
|
|
374
|
+
* unknown", which never counts as "too big".
|
|
375
|
+
*/
|
|
376
|
+
size?(path: string): Promise<number | undefined>;
|
|
355
377
|
/** True when the path is a symlink or a hard link (never planned/restored). */
|
|
356
378
|
isLink(path: string): Promise<boolean>;
|
|
357
379
|
}
|
|
@@ -455,6 +477,11 @@ export declare class SnapshotStore {
|
|
|
455
477
|
private lastEntryTime;
|
|
456
478
|
/** Store options; `dedup` toggles in-place content dedup (default on). */
|
|
457
479
|
private readonly dedup;
|
|
480
|
+
/**
|
|
481
|
+
* Per-file backup cap in bytes; `<= 0` means no cap. Capture paths consult it
|
|
482
|
+
* and the restore planner refuses a pre-cap record over an over-cap file.
|
|
483
|
+
*/
|
|
484
|
+
private readonly maxFileBytes;
|
|
458
485
|
/** Resolved checkpoint store root (absolute); see the constructor's fallback. */
|
|
459
486
|
readonly root: string;
|
|
460
487
|
/**
|
|
@@ -481,9 +508,19 @@ export declare class SnapshotStore {
|
|
|
481
508
|
constructor(root?: string, opts?: {
|
|
482
509
|
readonly dedup?: boolean;
|
|
483
510
|
readonly dshHome?: string;
|
|
511
|
+
/** Per-file backup cap in bytes; `<= 0` disables it. */
|
|
512
|
+
readonly maxFileBytes?: number;
|
|
484
513
|
});
|
|
485
514
|
/** Absolute path of one session's snapshot directory (id sanitized). */
|
|
486
515
|
sessionDir(sessionId: string): string;
|
|
516
|
+
/**
|
|
517
|
+
* True when `size` bytes is over the per-file cap, so it must not be backed
|
|
518
|
+
* up. `undefined` (a backend that cannot report a size) answers false: the
|
|
519
|
+
* guard fails toward STORING, never toward calling a file too big on a guess.
|
|
520
|
+
*/
|
|
521
|
+
isOverFileCap(size: number | undefined): boolean;
|
|
522
|
+
/** The cap in force, for reporting (0 = unlimited). */
|
|
523
|
+
get fileCapBytes(): number;
|
|
487
524
|
/** Absolute path of one anchor group directory. */
|
|
488
525
|
anchorDir(sessionId: string, anchorSeq: number): string;
|
|
489
526
|
/** Absolute file ref (relative to the session dir) of an entry. */
|
|
@@ -868,6 +905,12 @@ export declare class SnapshotStore {
|
|
|
868
905
|
* entries so no extra persistence is needed.
|
|
869
906
|
*/
|
|
870
907
|
trackedPaths(sessionId: string): Promise<Set<string>>;
|
|
908
|
+
/**
|
|
909
|
+
* The session's recorded paths whose current size is over the cap, so no
|
|
910
|
+
* backup of that state exists. One `stat` per recorded path; a path first seen
|
|
911
|
+
* over the cap was never recorded and is therefore not listed.
|
|
912
|
+
*/
|
|
913
|
+
uncoveredPaths(sessionId: string, probe?: DiskProbe): Promise<UncoveredPath[]>;
|
|
871
914
|
/**
|
|
872
915
|
* Summarize a session's on-disk footprint for a clear dry-run: anchor-group
|
|
873
916
|
* count, committed checkpoint-entry count (one per `.json` in an anchor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.14.0
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "In-window conversation rewind with workspace file restore · 同窗口内对话回退并可还原工作区文件",
|
|
5
5
|
"icon": "./assets/icon.svg",
|
|
6
6
|
"keywords": [
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@deepseek-ai/dsh-session": "^0.1.7-rc.2",
|
|
93
93
|
"@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
|
|
94
94
|
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
|
|
95
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
95
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
96
96
|
},
|
|
97
97
|
"peerDependenciesMeta": {
|
|
98
98
|
"@deepseek-ai/cordis": {
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
|
|
183
183
|
"@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
|
|
184
184
|
"@deepseek-ai/dsh-user-questions": "0.1.7-rc.2",
|
|
185
|
-
"@deepseek-ai/schemastery": "3.18.
|
|
185
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
186
186
|
"@types/node": "^24.0.0",
|
|
187
187
|
"@types/react": "^18.3.31",
|
|
188
188
|
"@types/react-dom": "^18.3.7",
|