dsh-rewind-plugin 0.5.0 → 0.6.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 CHANGED
@@ -40,7 +40,7 @@ dsh plugin --profile web add dsh-rewind-plugin
40
40
 
41
41
  ## Usage
42
42
 
43
- 1. Find the user message you want to rewind to in the conversation, or type `/rewind` to open the candidate picker.
43
+ 1. Find the user message you want to rewind to in the conversation, or type `/rewind` (or its alias `/undo`) to open the candidate picker.
44
44
  2. **Select it.** A small popover offers the two modes ("conversation and code" is only shown when there are restorable changes after the target).
45
45
  3. The rewind takes effect immediately: the conversation returns to how it looked at the target message, and the withdrawn message's text is filled back into the composer — edit and re-send.
46
46
 
@@ -56,6 +56,21 @@ dsh plugin --profile web add dsh-rewind-plugin
56
56
 
57
57
  </details>
58
58
 
59
+ ## Snapshot management
60
+
61
+ Snapshots (the before-write backups) are stored under `<dsh home>/rewind-snapshots/`
62
+ (`~/.dsh/rewind-snapshots/` when `$DSH_HOME` is unset). For the **same session**,
63
+ the plugin deduplicates snapshots by content (an unchanged file is stored as a link)
64
+ and keeps the newest 100 anchor groups. **Deleting that directory manually** only
65
+ clears the file backups (chat rewinds are unaffected) and the plugin rebuilds them
66
+ automatically.
67
+
68
+ A **global auto-cleanup** (off by default) removes the snapshot directories of
69
+ **long-inactive** sessions, leaving the active session and chat log untouched. Use
70
+ `/snapshot-auto-cleanup` to **view, configure, and run** it; the settings live in
71
+ `<dsh home>/snapshot-cleanup.json` and the last-sweep time in
72
+ `<dsh home>/snapshot-cleanup-last-sweep.json`. See: [Snapshot cleanup](docs/snapshot-auto-cleanup.md).
73
+
59
74
  ## Why it stands out
60
75
 
61
76
  Compared with the common approaches, here is the trade-off this plugin makes on "rewind":
@@ -140,7 +155,7 @@ withdrew should consume the stable, locale-independent helpers exported from
140
155
 
141
156
  ## Security
142
157
 
143
- This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials. Delete `~/.dsh/rewind-snapshots/` to wipe file backups only (chat rewinds are unaffected); the plugin rebuilds automatically. Full security model: [SECURITY.md](SECURITY.md).
158
+ This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `<dsh home>/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials. Delete `~/.dsh/rewind-snapshots/` to wipe file backups only (chat rewinds are unaffected); the plugin rebuilds automatically. For sessions you've left inactive for a long time, a global auto-cleanup (off by default) can remove their snapshot directory in whole, leaving the active session and the chat log untouched. Full security model: [SECURITY.md](SECURITY.md).
144
159
 
145
160
  ## Development
146
161
 
package/README.md CHANGED
@@ -40,7 +40,7 @@ dsh plugin --profile web add dsh-rewind-plugin
40
40
 
41
41
  ## 使用
42
42
 
43
- 1. 在对话中找到要回退的那条用户消息,或输入 `/rewind` 打开候选列表选择。
43
+ 1. 在对话中找到要回退的那条用户消息,或输入 `/rewind`(或其别名 `/undo`)打开候选列表选择。
44
44
  2. **选中它。** 小浮层提供两种模式(「回退对话和代码」仅在目标之后有可还原的变更时显示)。
45
45
  3. 回退立即生效:对话回到目标消息当时的样子,被撤回消息的文本自动填入输入框——改完直接重发。
46
46
 
@@ -58,9 +58,9 @@ dsh plugin --profile web add dsh-rewind-plugin
58
58
 
59
59
  ## 存储管理
60
60
 
61
- 快照(写前备份)存储于 `~/.dsh/rewind-snapshots/`。插件对**同一会话**的快照做内容去重(内容未变则存为链接)并保留最近 100 组锚点;**手动删除该目录**仅清除文件备份(对话回退不受影响),插件会自动重建。
61
+ 快照(写前备份)存储于 `<dsh home>/rewind-snapshots/`(未设 `$DSH_HOME` 时即 `~/.dsh/rewind-snapshots/`)。插件对**同一会话**的快照做内容去重(内容未变则存为链接)并保留最近 100 组锚点;**手动删除该目录**仅清除文件备份(对话回退不受影响),插件会自动重建。
62
62
 
63
- 另提供**全局自动清理**(默认关闭):把**长期不活跃**的会话快照整目录移除,不影响活动会话与对话日志。用 `/snapshot-auto-cleanup` 命令**查看与设置**,配置写入 `~/.dsh/snapshot-cleanup.json`。详见:[快照自动清理](docs/snapshot-auto-cleanup.zh.md)。
63
+ 另提供**全局自动清理**(默认关闭):把**长期不活跃**的会话快照整目录移除,不影响活动会话与对话日志。用 `/snapshot-auto-cleanup` 命令**查看、设置和运行**,配置写入 `<dsh home>/snapshot-cleanup.json`;最近一次自动清扫的时间记录在 `<dsh home>/snapshot-cleanup-last-sweep.json`。详见:[快照自动清理](docs/snapshot-auto-cleanup.zh.md)。
64
64
 
65
65
  ## 本插件的优势
66
66
 
package/SECURITY.md CHANGED
@@ -81,9 +81,11 @@ and is repaired offline (see `docs/compat/troubleshooting.md`).
81
81
 
82
82
  ## Filesystem containment
83
83
 
84
- - **Store root**: `~/.dsh/rewind-snapshots/` by default (`DSH_REWIND_SNAPSHOT_DIR`
85
- overrides it). The store never overlaps the workspace; deleting it only
86
- removes file backups and the store rebuilds from scratch.
84
+ - **Store root**: `<harness home>/rewind-snapshots/` by default (resolved via
85
+ `resolveDshHome`, so `~/.dsh/...` when `DSH_HOME` is unset); the `snapshotDir`
86
+ config, then `DSH_REWIND_SNAPSHOT_DIR` env, override it. The store never
87
+ overlaps the workspace; deleting it only removes file backups and the store
88
+ rebuilds from scratch.
87
89
  - **Path sanitization**: session ids and call ids are scrubbed to
88
90
  `[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
89
91
  root; `.` and `..` bare values are replaced — hostile ids cannot traverse
@@ -136,6 +138,10 @@ and is repaired offline (see `docs/compat/troubleshooting.md`).
136
138
  same operating-system user: state files are created with the process
137
139
  default permissions (no special modes are set — a standard umask applies),
138
140
  and the host user remains trusted.
141
+ - It does **not** exclude paths from the store: recording and restore mirror the
142
+ paths the model's file tools touched, so a sensitive file (e.g. `.env`) the
143
+ model may read or edit will be backed up and restorable. Keeping one out is
144
+ a **DSH model-permission** concern (a per-path deny), not a rewind feature.
139
145
  - It does **not** touch git (no refs, index, or worktree operations), makes
140
146
  **no network requests**, and does **not** access credentials.
141
147
  - It does **not** roll back whole-log state: telemetry, search, and `/export`
package/docs/README.md CHANGED
@@ -11,6 +11,7 @@ index/navigation entry point.
11
11
  | `architecture.md` | Module layering, rewind/checkpoint pipelines, compatibility strategy, roadmap | maintainers |
12
12
  | `format.md` | Durable on-disk format spec (checkpoint entries + restore journals) | maintainers |
13
13
  | `harness-reference.md` | DeepSeek Harness interface reference + plugin source layout | maintainers |
14
+ | `snapshot-auto-cleanup.md` | Global snapshot auto-cleanup policy and command (`.zh` mirror) | users / maintainers |
14
15
  | `contract/client-contract.md` | Rewind visibility contract for third-party DOM plugins (`.zh` mirror) | integrators |
15
16
  | `compat/audit.md` | Compatibility audit: verified surfaces, recorded findings, probe matrix | maintainers |
16
17
  | `compat/troubleshooting.md` | Known issues & repair steps (`.zh` mirror) | users / maintainers |
@@ -1,137 +1,149 @@
1
- # 兼容性排查记录(compat-audit
2
-
3
- > 排查方式:**测试即排查**。`tests/compat-invariants.test.ts`、`tests/compat-interop.test.ts`
4
- > `scripts/verify-host.mjs` 中的探针,把插件的真实执行路径插入 DSH 各子系统的
5
- > **真实消费路径**(真实 `@deepseek-ai/*` 包),断言兼容性不变量。探针失败即发现,
6
- > 进入「修复 / 钉住 / 记录」闭环。
1
+ # Compatibility audit (compat-audit)
2
+
3
+ > Method: **the tests are the audit**. The probes in `tests/compat-invariants.test.ts`,
4
+ > `tests/compat-interop.test.ts`, `tests/compat-gaps.test.ts` and
5
+ > `scripts/verify-host.mjs` drive the plugin's real execution paths through the DSH
6
+ > subsystems' **real consumer paths** (real `@deepseek-ai/*` packages) and assert
7
+ > compatibility invariants. A probe failure is a finding; it enters the
8
+ > fix/pin/record loop.
7
9
  >
8
- > 针对版本:npm `@deepseek-ai/*@0.1.1-rc.2`(与 `package-lock.json` 一致);
9
- > 源码参考:`oss/deepseek-harness` 本地 fork
10
+ > Targeted version: npm `@deepseek-ai/*@0.1.1-rc.2` (matches `package-lock.json`);
11
+ > source reference: the `oss/deepseek-harness` local fork.
10
12
  >
11
- > 版本适配说明:peerDependencies 采用 OR 并集(如 `^0.1.0-rc.6 || ^0.1.1-rc.2`),
12
- > 覆盖 DSH 已发布的 rc 元组系列。npm prerelease 匹配规则要求候选与范围内比较器
13
- > **同 `[major, minor, patch]` 元组**,因此 DSH 每次发布新元组(如未来的 `0.1.2-rc.x`、
14
- > `0.2.x`)时需追加并集项;同元组内 rc 滚动(如 `0.1.1-rc.2 rc.3`)无需动作。
15
- > 判断信号:`npm view @deepseek-ai/dsh version`;流程见 `scripts/check-dsh-version.mjs`。
13
+ > Version alignment: `peerDependencies` use an OR-union (e.g.
14
+ > `^0.1.0-rc.6 || ^0.1.1-rc.2`) covering each published rc tuple series. npm's
15
+ > prerelease matching rules require a candidate to share the range comparator's
16
+ > `[major, minor, patch]` tuple, so each new tuple series (e.g. a future
17
+ > `0.1.2-rc.x`, `0.2.x`) requires appending a union member; rc rolling within a
18
+ > tuple (`0.1.1-rc.2 → rc.3`) is a no-op. Signal: `npm view @deepseek-ai/dsh version`;
19
+ > flow: `scripts/check-dsh-version.mjs`.
16
20
 
17
- ## 「完全适配」的可执行定义(不变量)
21
+ ## Definition of "fully compatible" (invariants)
18
22
 
19
- | 不变量 | 含义 | 探针位置 |
23
+ | Invariant | Meaning | Probe location |
20
24
  |---|---|---|
21
- | I1 日志可重放 | rewind 后日志过 token-meter 重放、`Session.create`(resume preflight 同款校验)均不抛 | `compat-invariants` I1、`verify-host` 12/13 |
22
- | I2 surface 一致 | 切割后 surface 无重复节点、节点存在于日志、被撤目标不再回到 surface、`deriveMessages()` 合法 | `compat-invariants` I2 |
23
- | I3 step/turn 结构合法 | 客户端 turn-tail 顺序、step/start 唯一、step/end assistant/message 均有配对 step/start、无幽灵 turn | `compat-invariants` I3、`helpers.assertTurnTailOrdering` |
24
- | I4 折叠服务安全 | stats / title / goal / projection 对含 marker 日志折叠不抛且值可预测 | `compat-invariants` I4 |
25
- | I5 compact 互操作 | 取消 turn 遗留的 tool-call rewind 遮蔽后配对恢复平衡;跨 checkpoint rewind 明确拒绝;rewind compact 事务合法 | `compat-interop` I5、`verify-host` 12/14 |
26
- | I6 工具管线正确 | before-快照捕获/提交/恢复正确(既有 `snapshot.test.ts` + `verify-host` 4-8);取消时序不悬挂 | `verify-host` 4-815 |
27
- | I7 客户端顺序合法 | 含工具 turn、rewind marker、幽灵 step 的日志满足客户端 builder 顺序 | `compat-interop` I7 |
28
- | I8 运行时安全 | rewind/compact 组合不留下悬空 step/turn | `verify-host` 15 |
29
-
30
- ## 发现的兼容性问题
31
-
32
- ### R-OPENSTEP:日志存在未闭合 step 时,rewind 会破坏 token-meter 重放(**harness 侧问题;插件守卫已尝试并回退**)
33
-
34
- > **根因(harness 侧)**:崩溃遗留的未闭合 step 会让 token-meter 重放拒绝任何后续
35
- > step 活动。DSH `0.1.1-rc.2` 已在加载时用 `interruptedTurnClosers`
36
- > (`dsh-session/repair`)自动闭合崩溃遗留的 step/turn/tool 边界——**崩溃路径已根治**。
25
+ | I1 log replayability | A rewound log passes token-meter replay and `Session.create` (the resume-preflight validation) without throwing | `compat-invariants` I1, `verify-host` 12/13 |
26
+ | I2 surface consistency | After a cut, the surface has no duplicate nodes, every node exists in the log, the withdrawn target never returns to the surface, and `deriveMessages()` is legal | `compat-invariants` I2 |
27
+ | I3 step/turn structure | Client turn-tail ordering, unique `step/start`, every `step/end` and `assistant/message` has a paired `step/start`, no ghost turns | `compat-invariants` I3, `helpers.assertTurnTailOrdering` |
28
+ | I4 fold-service safety | stats / title / goal / projection fold a marker-bearing log without throwing, with predictable values | `compat-invariants` I4 |
29
+ | I5 compact interop | A tool-call orphaned by a cancelled turn is pair-balanced once shadowed by a rewind; a rewind across a compaction checkpoint is explicitly refused; a rewind-then-compact transaction stays legal | `compat-interop` I5, `verify-host` 12/14 |
30
+ | I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 48); cancellation timing never hangs | `verify-host` 48, 15 |
31
+ | I7 client ordering | A log carrying tool turns, rewind markers, and ghost steps satisfies the client builder ordering | `compat-interop` I7 |
32
+ | I8 runtime safety | `rewind`/`compact` combinations never leave a dangling step/turn frame | `verify-host` 15 |
33
+
34
+ ## Verified-compatible surfaces (probes pass)
35
+
36
+ - **token-meter replay** (marker + ghost-step frames + multiple rewinds + interleaved real turns + compact stacking).
37
+ - **compaction transactions**: `toolPairingBalancedBefore/After` stays balanced after a marker cut; the real `/compact` command (`command-compact` + `compaction-basic`, stub summarizer) can land `compaction/start…end` on top of a rewind marker and stay replayable; `/compact` is a legal no-op on a small surface.
38
+ - **resume replay**: `Session.create(id, events)` replays a rewind/compact-bearing log.
39
+ - **session-stats**: a ghost-step frame adds one step but no phantom turn (reuses the turn number).
40
+ - **session-title / goal fold**: a marker does not disturb `foldSessionTitle` / `foldGoal`.
41
+ - **client ordering**: turn-tail ordering + `step/start` uniqueness hold for tool turns + marker logs.
42
+ - **rewind across a compact checkpoint**: `RewindError('not-on-surface')` refuses cleanly, no crash.
43
+ - **plan-mode**: `hasOpenTurn` pairs only `turn/start`/`turn/end`; a marker produces no `turn/start`, so a rewind during active plan is unaffected (static review confirmed).
44
+ - **agent-loop cancellation**: `finally` guarantees step/turn closure; the rewind force-stop path leaves no dangling frame.
45
+
46
+ ## Known behavior boundaries (deterministic differences, non-crash, documented)
47
+
48
+ - **session-stats / session-telemetry fold the full log**: post-rewind stats do **not** rewind — `turns`/`steps`/`llmMs` still include withdrawn content; telemetry reports the marker and ghost-step frames one-by-one (under the reused old turn). This is the intended "fold the full log" semantics, pinned by probe.
49
+ - **token-meter usage anchor briefly reverts** (G3): after a rewind, the baseline falls back to the heuristic `estimated` until the next real-usage call restores it. Confirmed expected behavior (a marker inherently carries no usage); pinned by `compat-gaps` G3.
50
+ - **Withdrawn content stays searchable/exportable**: session-query full-text and `/export` read the raw log; a rewind cuts only the surface, so withdrawn messages remain (declared in the README).
51
+ - **Session title auto-regeneration**: the title derives from the surface, so an automatically-derived title may change after a rewind.
52
+ - **Files written but uncommitted in a cancelled turn**: a `both` rewind cannot restore them (tool side-effect timing; same as Claude Code).
53
+ - **Attachment files left after a message is shadowed**: attachment storage is not cleaned with the surface (`dsh-attachment-local` not installed, not automatically verified).
54
+
55
+ ## Upstream (harness) issues and the plugin's no-compensation stance
56
+
57
+ The plugin treats these as harness-side defects it does not compensate for. Each entry records the harness issue, its current status, and the plugin's stance, so a future maintainer does not "fix the wrong direction."
58
+
59
+ ### RU-I18N: host-side locale preference is not reliably readable at command registration (harness-side structural timing defect; plugin reads once, never retries)
60
+
61
+ - **Root cause (harness-side)**: the plugin resolves `activeLocale` in an
62
+ `ctx.inject(['settings'])` callback that reads `settings.get(settingsNamespace('locale')).preference`
63
+ **once, with no retry** (`src/index.ts`). `dsh-client-locale`'s host half
64
+ registers that `locale` settings section **through its own `ctx.inject(['settings'])`**
65
+ (`packages/client/locale/src/index.ts`). Both callbacks wait only on `settings` and are
66
+ independent of each other, so cordis makes **no ordering guarantee** between the
67
+ section registration and the plugin's read.
68
+ - **Observed failure**: a probe confirmed that when the plugin's read runs, `settings.get('locale')`
69
+ returns `undefined` (the section is not yet registered), so `activeLocale` stays at the
70
+ default `'en'` and — because the read is one-shot — is never corrected. The result is that
71
+ **all host-side `t()` output (runtime messages and command descriptions alike) renders in
72
+ English**, regardless of the user's language preference.
73
+ - **Not a user-config problem**: `settings.yaml` correctly carries `locale.preference: zh`;
74
+ reading it directly works. The defect is purely the host-side read racing the section
75
+ registration.
76
+ - **Plugin stance**: no compensation. The plugin does not add a lazy re-read, a retry loop, or a
77
+ post-locale description re-registration as a workaround, because the guarantee belongs to the
78
+ harness (make the locale preference available before plugin registration, or support per-locale
79
+ command descriptions). The plugin's `t()` design is retained; localized host output is treated
80
+ as an upstream capability to be restored when the harness provides it. (Command descriptions
81
+ are English for **every** host command — system plugins also pass raw English `description`
82
+ strings, e.g. `/goal` — so this behavior is consistent with the ecosystem, not a plugin
83
+ deviation.)
84
+
85
+ ### R-OPENSTEP: an unclosed `step` in the log lets a rewind break token-meter replay (harness-side; plugin guard attempted and reverted)
86
+
87
+ > **Root cause (harness-side)**: an unclosed step left by a crash makes token-meter replay
88
+ > reject any later step activity. DSH `0.1.1-rc.2` now auto-closes crash-left step/turn/tool
89
+ > boundaries at load via `interruptedTurnClosers` (`dsh-session`, consumed by
90
+ > `session-persistence/src/coordinator.ts`) — **the crash path is fixed**.
37
91
  >
38
- > **插件守卫(已尝试并回退)**:曾实现 `hasOpenStep` + `planRewind` 前置拒绝
39
- > (`open-step`),但在**真实会话日志上产生误判**(正常回退被拒、GUI 验证功能缺失),
40
- > revert(`177ec14`)。结论:**插件不设守卫**,接受残余风险(运行中第三方插件
41
- > 产生未闭合 step 时,rewind 可能破坏 /compact——该日志本身已异常,继续对话同样触发)。
42
- > 根治方向在 harness(token-meter 对未闭合 step 的恢复),不在插件。
92
+ > **Plugin guard (attempted and reverted)**: a `hasOpenStep` + `planRewind` pre-refusal was
93
+ > implemented (`open-step`) but misjudged on **real session logs** (normal rewinds refused, GUI
94
+ > verification broken), and was reverted (`177ec14`). Conclusion: **the plugin sets no guard**,
95
+ > accepting residual risk (an unclosed step produced by a third-party plugin can break
96
+ > `/compact` that log is already abnormal, and continuing the conversation triggers the same).
97
+ > The fix direction is in the harness (token-meter recovery for unclosed steps), not the plugin.
43
98
 
44
- #### 未闭合 `step/start` 的具体触发情况(源码确认)
99
+ #### Concrete `step/start` trigger paths (source-confirmed)
45
100
 
46
- 全仓**只有一处** append `step/start`:`packages/core/agent-loop/src/agent.ts:279`(官方包内
47
- 无其他生产者;`session/end-seed` 等修复只做 torn-write 截断,不处理逻辑未闭合)。
101
+ The tree has exactly **one** `append('step/start')` producer: `packages/core/agent-loop/src/agent.ts:279`
102
+ (no other producer inside the official packages; `session/end-seed` etc. only truncate torn writes,
103
+ not logically-unclosed steps).
48
104
 
49
- | # | 触发路径 | 现实性 | 依据 |
105
+ | # | Trigger path | Plausibility | Basis |
50
106
  |---|---|---|---|
51
- | P1 | **进程非优雅终止**:`step/start` write-behind 批量落盘(`session-persistence/src/write-behind.ts`,`maxDelayMs` 后写一批)→ step 执行中(LLM 流式/工具,秒级到分钟级)→ SIGKILL / OOM-kill / 断电 / WSL 强关 → `step/end`(在 `finally`,进程活着才执行)未落盘 | **最现实** | `agent.ts:292` finallywrite-behind 批量;torn-write 修复只截断写一半的行 |
52
- | P2 | **第三方插件 bug**:官方包只有 agent-loop 一个生产者,但外部插件可任意 `session.append('step/start', …)` 不闭合 | 可能 | 公开 `Session.append` |
53
- | P3 | **手工编辑会话文件**:改 `~/.dsh/…/session.jsonl[.zstd]`(zstd 需解压/重压;plaintext 配置可直接改) | 可能但费事 | `persistence-jsonl/format.ts`(`JsonlCompression = 'zstd' \| 'none'`) |
54
- | P4 | **append 自身故障**:`finally` `append('step/end')` 抛错(payload 是纯数字,几乎不可能) | 理论 | `agent.ts:292` |
55
-
56
- **放大机制(rewind 不是唯一触发者)**:崩溃后 resumeagent-loop `turn()` 直接
57
- `phase.turn + 1` 开新 turn(`agent.ts:251-255`),**不闭合遗留 step**——所以「继续对话」
58
- (新 `step/start`)同样踩中 token-meter 校验。影响范围精确界定:
59
-
60
- - **对话本身不受影响**(请求路径不调用 `tokenMeter.measure`,全仓仅 compaction-basic 调用);
61
- - **手动 `/compact` 永久报错**(`compactNow` 首步 `measure()` 抛原始错误);
62
- - **自动压缩永久静默失效**(`agent/pre-step` 钩子 catch 后仅 warn,对话继续);
63
- - **rewind 的角色**:若用户先 rewind(而非继续对话),幽灵 step/start 成为第一个踩中者,
64
- 且插件无防御性检测——把「局部异常日志」升级为「用户可感知的 /compact 失效」。
65
-
66
- ### G3(已确认:合理行为,非缺陷):rewind 让 token-meter 的 usage 锚点短暂失效
67
-
68
- - **机制**:rewind marker 是日志最后一条 `assistant/message` 且无 `usage`,token-meter
69
- `_sync` 重放以它收尾,把 `MeasurementAnchor.baseline` provider 实测 `usage` 覆盖为
70
- 启发式 `estimated`——直到下一条带 usage 的真实消息才恢复(探针验证了完整链条:
71
- `usage rewind estimated 真实 turn usage`)。
72
- - **定性(已确认)**:marker 语义上就是空消息,不携带 usage 是正确的;锚点短暂退回估算
73
- 是该语义的自然结果,且短暂、自愈、不破坏功能。**属于预期行为,不修复**。
74
- - **探针**:`tests/compat-gaps.test.ts` → `G3`(钉住该行为,防止未来 harness 变更改变它)。
75
-
76
- ## 已验证兼容的面(探针通过)
77
-
78
- - **token-meter 重放**(含 marker + 幽灵 step 帧 + 多次 rewind + 交错真实 turn + compact 叠加)。
79
- - **compaction 事务**:`toolPairingBalancedBefore/After` 对 marker 切割后的 surface 恒平衡;
80
- 真实 `/compact` 命令(`command-compact` + `compaction-basic`,stub summarizer)可在 rewind
81
- marker 之上落地 `compaction/start…end` 并保持可重放;小 surface 时 `/compact` 合法 no-op。
82
- - **resume 重放**:`Session.create(id, events)` 对含 rewind/compact 的日志重放通过。
83
- - **session-stats**:ghost step 帧只 +1 step、不新增 phantom turn(复用 turn 号)。
84
- - **session-title / goal fold**:marker 不干扰 `foldSessionTitle` / `foldGoal`。
85
- - **客户端顺序**:turn-tail ordering + step/start 唯一性对工具 turn + marker 日志成立。
86
- - **跨 compact checkpoint 的 rewind**:`RewindError('not-on-surface')` 明确拒绝,不崩溃。
87
- - **plan-mode**:`hasOpenTurn` 只配对 `turn/start`/`turn/end`,marker 不产生 `turn/start`,
88
- 激活 plan 期间 rewind 无影响(静态审查确认)。
89
- - **agent-loop 取消**:`finally` 保证 step/turn 闭合,rewind 的 force-stop 路径不悬挂帧。
90
-
91
- ## 已知边界(行为差异,非崩溃,文档化)
92
-
93
- - **session-stats / session-telemetry 折叠完整日志**:rewind 后统计**不回退**——`turns` /
94
- `steps` / `llmMs` 仍含被撤内容;telemetry 逐条上报 marker 与幽灵 step 帧(归入被复用
95
- 的旧 turn)。这是「折叠完整日志」的预期语义,探针钉住该行为。
96
- - **token-meter usage 锚点短暂失效**(G3):rewind 后 baseline 退回启发式 `estimated`,
97
- 下一条真实 usage 调用恢复(探针钉住)。
98
- - **被撤内容仍可搜索、可导出**:session-query 全文搜索与 `/export` 基于原始日志,
99
- rewind 只切 surface,被撤消息仍在其中(README 已声明)。
100
- - **session-title 自动重生成**:标题由 surface 派生,rewind 后自动标题可能变化。
101
- - **取消 turn 中已写盘但未提交快照的文件**:rewind both 无法恢复(工具副作用时序,
102
- Claude Code 同限制)。
103
- - **附件消息被遮蔽后文件残留**:attachment 存储不随 surface 清理(`dsh-attachment-local`
104
- 未装,未自动化验证)。
105
-
106
- ## 未覆盖边界(需要额外端到端层,不阻塞)
107
-
108
- - 真实 LLM 流式与自动标题生成(L2 stub 化)。
109
- - 真实 SQLite 索引生命周期(`dsh-session-query-sqlite` 未安装、含原生依赖)。
110
- - 浏览器端实际渲染 replay(客户端契约已由 `client-contract.test.ts` 覆盖逻辑层)。
111
- - 真实 JSONL 持久化往返(`dsh-session-persistence-jsonl` 依赖 native `koffi`;往返验证的是
112
- harness 自身 zstd/JSON 编解码,插件不参与,价值/成本不划算,未安装)。
113
- - session-reference 的 `SessionReferenceResolver.prepare` 需完整 session-query 服务;其数据
114
- 基础(current-surface 投影)已由 G1 探针(`foldSurface` 转换)覆盖。
115
- - telemetry 管道(`dsh-session-telemetry-otel`)与附件 provider(`dsh-attachment-local`)。
116
- - 运行中的 workflow/jobs 被 rewind 取消:工具契约要求 observe `exec.signal` 并 settle
117
- (`packages/core/tools/src/index.ts`),rewind 触发的是 harness 标准取消,非插件特有——
118
- 静态确认,未实测真实 workflow。
119
-
120
- ## 排查矩阵(子系统 × 不变量)
121
-
122
- | DSH 子系统 | I1 | I2 | I3 | I4 | I5 | I6 | I7 | I8 |
107
+ | P1 | **Abnormal process termination**: `step/start` is batched to disk (write-behind, `maxDelayMs` per batch) → the step is mid-execution (LLM stream/tool, seconds to minutes) → SIGKILL / OOM-kill / power loss / WSL hard-close → `step/end` (in `finally`, only runs while the process is alive) is never persisted | **Most realistic** | `agent.ts:292` finally; write-behind batching; torn-write fix truncates only a half-written line |
108
+ | P2 | **Third-party plugin bug**: only the official agent-loop produces one, but external plugins may `session.append('step/start', …)` and never close it | possible | public `Session.append` |
109
+ | P3 | **Manual session-file editing**: edit `~/.dsh/…/session.jsonl[.zstd]` (zstd needs decompress/recompress; plaintext config edits directly) | possible but laborious | `persistence-jsonl/format.ts` (`JsonlCompression = 'zstd' \| 'none'`) |
110
+ | P4 | **append itself failing**: `append('step/end')` in `finally` throws (payload is plain numbers, nearly impossible) | theoretical | `agent.ts:292` |
111
+
112
+ **Amplifier (rewind is not the only trigger)**: after a crash resume, agent-loop `turn()` opens a
113
+ new turn at `phase.turn + 1` (`agent.ts:251-255`) **without closing the leftover step** — so
114
+ "continue the conversation" (a new `step/start`) trips the same token-meter check. Scope:
115
+
116
+ - **The conversation itself is unaffected** (the request path does not call `tokenMeter.measure`; only compaction-basic does tree-wide).
117
+ - **Manual `/compact` fails permanently** (`compactNow`'s first `measure()` throws the raw error).
118
+ - **Automatic compaction silently stays disabled** (the `agent/pre-step` hook catches and warns; the conversation continues).
119
+ - **rewind's role**: if the user rewinds first (rather than continuing), the ghost `step/start` becomes the first trip-wire, and the plugin has no defensive detection — upgrading a "locally abnormal log" into a "user-visible `/compact` failure."
120
+
121
+ ## Uncovered boundaries (need an additional e2e layer; non-blocking)
122
+
123
+ - Real LLM streaming and auto title generation (L2 stubbed).
124
+ - Real SQLite index lifetime (`dsh-session-query-sqlite` not installed; native deps).
125
+ - Actual browser rendering replay (the client contract's logic layer is covered by `client-contract.test.ts`).
126
+ - Real JSONL persistence round-trip (`dsh-session-persistence-jsonl` depends on native `koffi`; the round-trip validates the harness's own zstd/JSON codec, the plugin is not party to it, not worth the cost, not installed).
127
+ - `session-reference`'s `SessionReferenceResolver.prepare` needs a full session-query service; its data base (current-surface projection) is already covered by the G1 probe (`foldSurface`).
128
+ - telemetry pipeline (`dsh-session-telemetry-otel`) and the attachment provider (`dsh-attachment-local`).
129
+ - Running workflow/jobs cancelled by a rewind: the tool contract requires observing `exec.signal` and settling (`packages/core/tools/src/index.ts`); a rewind triggers the harness's standard cancel, not plugin-specific — statically confirmed, real workflows untested.
130
+
131
+ ## Audit matrix (subsystem × invariant)
132
+
133
+ | DSH subsystem | I1 | I2 | I3 | I4 | I5 | I6 | I7 | I8 |
123
134
  |---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
124
- | 会话内核(append/surface/deriveMessages | ✓ | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
135
+ | Session core (append/surface/deriveMessages) | ✓ | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
125
136
  | token-meter | ✓ | — | ✓ | — | ✓ | — | — | — |
126
- | compaction(事务/命令/tool-pairing | ✓ | — | — | — | ✓ | — | — | ✓ |
137
+ | compaction (transaction/command/tool-pairing) | ✓ | — | — | — | ✓ | — | — | ✓ |
127
138
  | session-stats / projection | — | — | ✓ | ✓ | — | — | — | — |
128
139
  | session-title | — | — | — | ✓ | — | — | — | — |
129
140
  | goal | — | — | — | ✓ | — | — | — | — |
130
- | resume / session-query 重放 | ✓ | — | ✓ | — | — | — | ✓ | — |
131
- | 工具管线(快照/恢复) | — | — | — | — | — | ✓ | — | ✓ |
132
- | 客户端顺序 | — | — | ✓ | — | — | — | ✓ | — |
133
- | plan-mode | — | — | — | — | — | — | — | ✓(静态) |
134
-
135
- ✓ = 探针通过;— = 不适用。R-OPENSTEP 见上(harness 已修、插件不设守卫)。G3 见「已知边界」。
136
- 缺口探针 `tests/compat-gaps.test.ts`:G1 surface 分类(`foldSurface` current/shadowed/log-only)
137
- G2 projection checkpoint 均验证通过;G3 钉住 token-meter baseline 行为差异。
141
+ | resume / session-query replay | ✓ | — | ✓ | — | — | — | ✓ | — |
142
+ | tool pipeline (snapshot/restore) | — | — | — | — | — | ✓ | — | ✓ |
143
+ | client ordering | — | — | ✓ | — | — | — | ✓ | — |
144
+ | plan-mode | — | — | — | — | — | — | — | (static) |
145
+
146
+ ✓ = probe passes; — = not applicable. RU-I18N and R-OPENSTEP are upstream issues the plugin
147
+ does not compensate for (see above); G3 is a confirmed-non-defect behavior pinned in
148
+ `compat-gaps.test.ts` (G1 surface classification via `foldSurface` and G2 projection checkpoint
149
+ both pass).
package/docs/format.md CHANGED
@@ -7,10 +7,11 @@ and this spec disagree, the code wins and this spec is a bug.
7
7
 
8
8
  ## State root
9
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.
10
+ The store root defaults to `<harness home>/rewind-snapshots/` the dsh data
11
+ directory (`~/.dsh/rewind-snapshots/` when `DSH_HOME` is unset) — overridable in
12
+ order by the `snapshotDir` plugin config, then the `DSH_REWIND_SNAPSHOT_DIR`
13
+ environment variable. It is a sibling of the workspace, never a subtree of it.
14
+ Deleting the root only removes file backups; the store rebuilds from scratch.
14
15
 
15
16
  ```
16
17
  <root>/
@@ -54,7 +54,7 @@ src/index.ts host plugin: /rewind command + checkpoint pipeline (tool
54
54
  src/rewind.ts pure planning: target resolution, surface range, candidate listing
55
55
  src/snapshot.ts checkpoint store (disk before-backups, restore/preview, bounded prune)
56
56
  src/session-cwd.ts session-cwd resolution (fs-tools rule)
57
- src/client/index.ts client plugin: per-message ↶ button + manual /rewind guard
57
+ src/client/index.ts client plugin: /rewind command decoration + per-message ↶ button portals
58
58
  src/client/popover.ts mode-selection popover (both-mode impact confirm)
59
59
  src/client/hidden.ts withdrawn-span computation (hiddenSeqsOf), pure
60
60
  src/client/locales.ts zh / en copy (LocaleNamespaceMap)
@@ -1,71 +1,90 @@
1
- # Snapshot auto-cleanup
1
+ # Snapshot cleanup
2
2
 
3
- The rewind store writes one on-disk `before` backup per tracked file change,
4
- grouped by its anchor message. Snapshots are deduped within a session and capped
5
- at the newest 100 anchor groups, but across sessions that are no longer active
6
- the store can
7
- still grow without bound. `snapshot-auto-cleanup` is an OPTIONAL global policy
8
- (off by default) that removes the whole snapshot directory of a session that has
9
- been **long-inactive** — untouched past a configurable idle cutoff.
3
+ The plugin saves a backup of each file before it's edited, so you can rewind your
4
+ code to an earlier point. These backups are called **snapshots**, and they're
5
+ grouped by message and stored per session.
10
6
 
11
- It only ever removes the whole snapshot **directory** of a long-inactive session.
12
- It never touches the active session's snapshots, never touches the conversation
13
- log, and leaves snapshot data within the idle cutoff alone.
7
+ `/snapshot-auto-cleanup` helps you manage those snapshots whether you want to
8
+ reclaim disk space from old sessions or clear the **current** session so its
9
+ rewind history starts fresh.
10
+
11
+ ## What it does
12
+
13
+ **Automatic cleanup** (off by default): the plugin can remember which sessions
14
+ you've stopped using, and now and then remove their snapshots to keep disk usage
15
+ down. It never touches your active session, and it never touches your
16
+ conversation.
17
+
18
+ **Manual:** even with automatic cleanup off, you can run the cleanup yourself:
19
+
20
+ - `/snapshot-auto-cleanup run [--apply]` — preview, then actually remove the
21
+ snapshots of sessions you haven't used for a while.
22
+ - `/snapshot-auto-cleanup run --current [--apply]` — preview, then actually
23
+ clear the **current** session's snapshots. This resets its rewind history to
24
+ "from now on" (your conversation is unaffected). If a turn is currently
25
+ running, the plugin pauses it first, then clears.
14
26
 
15
27
  ## Commands
16
28
 
17
29
  ```
18
- /snapshot-auto-cleanup show status (enabled, max-age, config path)
19
- /snapshot-auto-cleanup on|off enable/disable the automatic sweep
20
- /snapshot-auto-cleanup max-age <days> set the idle cutoff (positive integer)
21
- /snapshot-auto-cleanup run dry-run: list what would be removed
22
- /snapshot-auto-cleanup run --apply actually remove those sessions
30
+ /snapshot-auto-cleanup show the current settings
31
+ /snapshot-auto-cleanup on|off turn automatic cleanup on or off
32
+ /snapshot-auto-cleanup max-age <days> how many idle days before a session's snapshots are removed
33
+ /snapshot-auto-cleanup run preview what the automatic cleanup would remove
34
+ /snapshot-auto-cleanup run --apply actually remove those snapshots
35
+ /snapshot-auto-cleanup run --current preview clearing this session's snapshots
36
+ /snapshot-auto-cleanup run --current --apply actually clear this session's snapshots
23
37
  ```
24
38
 
25
- `run` is a manual escape hatch and works whether or not the automatic cleanup is
26
- `on`. `run` defaults to a dry-run; add `--apply` to execute.
39
+ `run` always starts as a preview; add `--apply` to make the change. `run` works
40
+ whether or not automatic cleanup is on.
27
41
 
28
- ## Config file
42
+ ## Settings
29
43
 
30
- The policy is persisted to `~/.dsh/snapshot-cleanup.json`:
44
+ The settings live in `<dsh home>/snapshot-cleanup.json`:
31
45
 
32
46
  ```json
33
47
  { "enabled": false, "maxAgeDays": 30 }
34
48
  ```
35
49
 
36
- - `enabled` — whether the automatic sweeps run (default `false`).
37
- - `maxAgeDays` — how many idle days before a long-inactive session's snapshot dir is
38
- removed (default `30`; `0`/negative are rejected, so a broken config can never
39
- delete everything).
40
-
41
- Override the path with the `DSH_SNAPSHOT_CLEANUP_CONFIG` environment variable.
42
- The file is written only by the `/snapshot-auto-cleanup` command. A missing file
43
- reads as the safe default (off); a missing or corrupt file makes a sweep
44
- **fail-closed** (delete nothing) and log a warning, and is surfaced when you run
45
- the command again.
46
-
47
- ## When it runs
48
-
49
- The 24h window is anchored on a **persisted** last-sweep time
50
- (`~/.dsh/snapshot-cleanup-last-sweep.json`), so a host restart does not reset
51
- it: the auto-sweep checks **once per process run**, on the first session
52
- activity of a window (a user message or a completed tool call), and cleans only
53
- when enabled **and** >=24h since the last sweep. It runs in the background and
54
- never blocks the activity that triggered it. Because the check happens once per
55
- run, a change that takes effect immediately is best applied with
56
- `/snapshot-auto-cleanup run`; editing the config file by hand (or enabling after
57
- the run's first activity) takes effect on the next run.
58
-
59
- ## Safety and boundaries
60
-
61
- - Removes only whole **long-inactive** session dirs; the active session and the
62
- conversation log are never touched.
63
- - "Inactive" is judged by mtime: a session still being written to keeps scrolling
64
- its newest member stamp forward, so it is never old enough to be pruned.
65
- - Dedup `ref` links are session-relative, so removing a whole dir cannot dangle a
66
- link elsewhere.
67
- - Trade-off: enabling auto-cleanup means a session resumed after a long idle gap
68
- will rewind only from its remaining (newest 100) anchors; its old snapshots are
69
- gone. The conversation log is never affected.
70
- - Deleting the whole store dir manually stays safe (it is recreated on the next
71
- capture); auto-cleanup just scopes that removal to long-inactive sessions.
50
+ - `enabled` — whether automatic cleanup runs (default `false`).
51
+ - `maxAgeDays` — how many idle days before a session's snapshots are removed
52
+ (default `30`). Only positive numbers are accepted, so a broken setting can
53
+ never delete everything.
54
+
55
+ You can point the plugin at a different file with the `DSH_SNAPSHOT_CLEANUP_CONFIG`
56
+ environment variable. The file is changed only when you turn cleanup on/off or
57
+ set `max-age`; a missing file reads as the safe default (off).
58
+
59
+ ## When automatic cleanup runs
60
+
61
+ Automatic cleanup checks at most **once per run** (a restart lets it check again),
62
+ on the first session activity (a message you send or a tool call that finishes),
63
+ and only when it's enabled and at least 24 hours have passed since the last
64
+ check. The 24-hour clock is saved to `<dsh home>/snapshot-cleanup-last-sweep.json`,
65
+ so restarting doesn't reset it. It runs in the background and never blocks
66
+ what you're doing.
67
+
68
+ If you want a change to take effect right away, use `run`; automatic cleanup
69
+ picks up a fresh setting on the next run.
70
+
71
+ ## Safety
72
+
73
+ - Only rewind **snapshots** (the file backups) are ever removed. Your
74
+ conversation is never touched, and the plugin never rewrites or deletes your
75
+ session history.
76
+ - Automatic cleanup never removes your **active** session's snapshots — only
77
+ sessions that have been idle past the cutoff.
78
+ - `run --current` clears the current session's snapshots. This is one-way for
79
+ that session's file-rewind history: you can't rewind code to before the clear,
80
+ but your conversation stays intact, and the session starts recording fresh
81
+ snapshots from now on.
82
+
83
+ ## Known limitation
84
+
85
+ The plugin keeps the most recent **100 messages'** snapshots per session. If you
86
+ rewind or compact a lot in one long session, those 100 slots can be taken up by
87
+ messages that are no longer reachable, so you may find you can't rewind as far
88
+ back as you'd like. (Claude Code behaves the same way.) To get back to a clean
89
+ state, run `/snapshot-auto-cleanup run --current --apply` to clear the current
90
+ session and start fresh.