dsh-rewind-plugin 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -5
- package/README.zh.md +13 -4
- package/docs/client-contract.md +56 -0
- package/docs/client-contract.zh.md +54 -0
- package/docs/compat-audit.md +137 -0
- package/docs/harness-reference.md +10 -1
- package/docs/release.md +18 -0
- package/docs/troubleshooting.md +29 -24
- package/docs/troubleshooting.zh.md +4 -21
- package/lib/client.js +27 -2
- package/lib/index.js +22 -4
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/rewind.d.ts +37 -0
- package/package.json +35 -31
- package/scripts/repair-markers.mjs +0 -176
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ The plugin appends an **empty-content marker** `assistant/message` into the sess
|
|
|
66
66
|
- The marker carries `sourceEventSeqs` covering every shadowed node, and `Session.append`'s surface rules validate the cut (a contiguous range on the current surface).
|
|
67
67
|
- Because the marker is **empty**, the harness derives it to `null` — it never enters the model context and never renders as conversation content. Agent and user both see the conversation exactly as it was at the target.
|
|
68
68
|
- The marker's **turn number reuses the last started turn** (`markerTurnOf`), never `lastTurn + 1`: the harness numbers its next real turn exactly `last turn/start + 1`, so a `maxTurn + 1` marker would sit *before* that `turn/start` — the client conversation builder rejects the ordering (`…turn-tail… received an update before its start Match`) and history load fails. Reusing an already-consumed turn makes the marker a harmless trailing update on the previous completed turn's tail — it can never collide with a future turn.
|
|
69
|
+
- The marker rides a **ghost step frame** — its own `step/start` … `step/end` with a fresh step number (`markerStepOf`) — because the harness token-meter requires every `assistant/message` to sit inside an open step of the same turn/step; a bare idle-time marker would fail its replay and break `/compact` for the session.
|
|
69
70
|
- The append-only log is **untouched** — every withdrawn event stays in the audit trail; only the model-visible surface is cut, so the next request derives its context from the target onward.
|
|
70
71
|
|
|
71
72
|
A running turn (LLM thinking / streaming) is force-stopped first (`cancel({ kind: 'user' })`) and the rewind waits for quiescence; if it can't stop, the rewind is aborted with an error.
|
|
@@ -109,11 +110,19 @@ The essential difference: dsh-turn-rewind keeps the log immutable and therefore
|
|
|
109
110
|
> This project and DeepSeek Harness are both in developer preview. Pin exact
|
|
110
111
|
> versions in reproducible environments and review the behavior notes above.
|
|
111
112
|
|
|
113
|
+
## Client contract
|
|
114
|
+
|
|
115
|
+
Third-party DOM plugins that need to know which transcript rows a rewind
|
|
116
|
+
withdrew should consume the stable, locale-independent helpers exported from
|
|
117
|
+
`dsh-rewind-plugin/client` (`hiddenSeqsOf`, `targetSeqOfArgs`) — never parse
|
|
118
|
+
`outcome.text`. The `data-dsh-rewind-hidden` attribute marks withdrawn rows
|
|
119
|
+
(observational only). Details: [docs/client-contract.md](docs/client-contract.md).
|
|
120
|
+
|
|
112
121
|
## Known issues
|
|
113
122
|
|
|
114
|
-
Rewinds created with versions `≤ v0.2.4` could corrupt client replay when followed by more conversation (a marker turn collides with the next `turn/start`).
|
|
123
|
+
Rewinds created with versions `≤ v0.2.4` could corrupt client replay when followed by more conversation (a marker turn collides with the next `turn/start`). Only pre-upgrade sessions are affected. The offline repair tool (`dsh-rewind-repair`) was shipped before v0.4.0 and is no longer provided from v0.4.0 on — install a pre-v0.4.0 release if you need it ([docs/troubleshooting.md](docs/troubleshooting.md)).
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
Rewinds from `≤ v0.3.3` appended a bare marker (no step frame); the harness token-meter rejects such a log on replay, so `/compact` fails for that session. Newer versions are compatible; affected old sessions have no online repair yet — start a new session.
|
|
117
126
|
|
|
118
127
|
## Security
|
|
119
128
|
|
|
@@ -125,12 +134,26 @@ This plugin only appends rewind-marker events to the session log; it never delet
|
|
|
125
134
|
|
|
126
135
|
```sh
|
|
127
136
|
npm install # devDeps from the npm registry
|
|
128
|
-
npm run typecheck # tsc on
|
|
129
|
-
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
|
|
137
|
+
npm run typecheck # tsc on all three surfaces (host + client + client-test)
|
|
138
|
+
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration / compat-invariants / compat-interop
|
|
130
139
|
npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
131
|
-
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
|
|
140
|
+
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end (incl. real /compact after rewind)
|
|
132
141
|
```
|
|
133
142
|
|
|
143
|
+
`npm test` and `verify-host` include the **compatibility probe suites**
|
|
144
|
+
([docs/compat-audit.md](docs/compat-audit.md)): scenario-generated logs drive the
|
|
145
|
+
real harness packages (token-meter, compaction, session-stats/title/goal folds,
|
|
146
|
+
resume preflight) through rewind markers and assert the compatibility
|
|
147
|
+
invariants. A failing probe is a discovered incompatibility, not a mock
|
|
148
|
+
artifact. One finding is recorded: **R-OPENSTEP** — a log carrying an
|
|
149
|
+
unclosed `step/start` (crash leftover) makes any later step activity,
|
|
150
|
+
including a rewind's ghost-step frame, break token-meter replay (and
|
|
151
|
+
/compact). Harness `0.1.1-rc.2` fixes the crash path (`interruptedTurnClosers`
|
|
152
|
+
closes leftover step/turn boundaries on load). A plugin-side guard was tried
|
|
153
|
+
and reverted: it produced false positives on real session logs (rewind
|
|
154
|
+
feature broken), so the plugin deliberately ships no guard — the residual
|
|
155
|
+
risk (runtime-produced unclosed steps) is accepted.
|
|
156
|
+
|
|
134
157
|
`prepare` runs the full build, so git installs and `npm pack` / `npm publish` always produce a complete `lib/` and the `LICENSE`.
|
|
135
158
|
|
|
136
159
|
Maintainers: the module map and harness interface reference live in [docs/harness-reference.md](docs/harness-reference.md); publishing steps in [docs/release.md](docs/release.md).
|
package/README.zh.md
CHANGED
|
@@ -66,6 +66,7 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
66
66
|
- 标记携带 `sourceEventSeqs` 覆盖所有被遮蔽节点,`Session.append` 的 surface 规则校验切割合法性(仅限当前 surface 上的连续区间)。
|
|
67
67
|
- 因为标记**内容为空**,harness 会将其派生为 `null`——永不进入模型上下文、也永不渲染成对话内容。agent 与用户看到的对话都回到目标消息当时的样子。
|
|
68
68
|
- 标记的 **turn 号复用最后一个已开始的回合**(`markerTurnOf`),而不是「最后回合 + 1」:harness 恰好用 `最后 turn/start + 1` 编号下一条真实回合。若标记也取这个数,日志里就会出现同一 turn 的 `assistant/message` 先于 `turn/start` 的乱序,客户端 conversation 构建器会以 `conversation Context …:turn-tail… received an update before its start Match` 拒绝重放——历史加载失败、整个对话从界面消失。复用已消费的 turn 号则标记只是上一个已完成回合尾部的一次无害追加,永不与新回合冲突。
|
|
69
|
+
- 标记自带**幽灵步骤框架**——自己的 `step/start` … `step/end`,step 号取该回合未用过的新号(`markerStepOf`):harness 的 token-meter 重放要求每条 `assistant/message` 位于打开的 step 内,裸标记则会让该会话的 `/compact` 失效。
|
|
69
70
|
- append-only 日志**不被改写**——审计轨迹完整保留每条被撤回的事件,只有模型可见的 surface 被剪掉,下一条请求从目标消息起派生上下文。
|
|
70
71
|
|
|
71
72
|
若 agent 正在运行(LLM 思考/流式输出),会先强制停止(`cancel({ kind: 'user' })`)并等待 quiescence 再回退;停不下来则中止并报错。
|
|
@@ -98,7 +99,7 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
98
99
|
| 跟踪范围 | 仅写类工具编辑(同 Claude Code) | 任意 Git 管理文件(要求 Git worktree) |
|
|
99
100
|
| 公共服务 API | 无——聚焦单用途插件 | 有——`ctx.changeLedger` 服务 + `/turn-rewind` HTTP 端点 |
|
|
100
101
|
|
|
101
|
-
本质区别:dsh-turn-rewind 因保持日志不可变而必须派生新会话;本插件用空标记**就地剪掉**模型可见 surface
|
|
102
|
+
本质区别:dsh-turn-rewind 因保持日志不可变而必须派生新会话;本插件用空标记**就地剪掉**模型可见 surface,于是原对话在同一个窗口继续——这段并不平凡的实现正是 dsh-turn-rewind 绕开的部分。
|
|
102
103
|
|
|
103
104
|
## 兼容性
|
|
104
105
|
|
|
@@ -109,11 +110,19 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
109
110
|
> 本项目与 DeepSeek Harness 均处于开发者预览阶段。可复现环境请 pin 精确版本,
|
|
110
111
|
> 并阅读上述行为说明。
|
|
111
112
|
|
|
113
|
+
## 客户端契约
|
|
114
|
+
|
|
115
|
+
需要获知哪些转录行被回退撤回的第三方 DOM 插件,应使用
|
|
116
|
+
`dsh-rewind-plugin/client` 导出的稳定、与本地化无关的纯函数
|
|
117
|
+
(`hiddenSeqsOf`、`targetSeqOfArgs`),切勿解析 `outcome.text`。
|
|
118
|
+
`data-dsh-rewind-hidden` 属性标记被撤回的行(仅观测性)。
|
|
119
|
+
详见:[docs/client-contract.zh.md](docs/client-contract.zh.md)。
|
|
120
|
+
|
|
112
121
|
## 已知问题
|
|
113
122
|
|
|
114
|
-
`v0.2.4` 及之前版本创建的回退在随后继续对话时可能损坏客户端重放(标记 turn 与下一个 `turn/start`
|
|
123
|
+
`v0.2.4` 及之前版本创建的回退在随后继续对话时可能损坏客户端重放(标记 turn 与下一个 `turn/start` 撞号)。只影响升级前就已存在的旧会话。离线修复工具(`dsh-rewind-repair`)在 v0.4.0 之前随包提供,此后不再提供——需要修复的用户可安装 v0.4.0 之前的版本([完整步骤](docs/troubleshooting.zh.md))。
|
|
115
124
|
|
|
116
|
-
|
|
125
|
+
`v0.3.3` 及之前版本的回退以裸标记追加(无步骤框架),token-meter 重放会拒绝该日志,`/compact` 对该会话失效。新版本均已兼容;已受影响的旧会话暂不提供在线修复——请新建会话。
|
|
117
126
|
|
|
118
127
|
## 安全
|
|
119
128
|
|
|
@@ -125,7 +134,7 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
125
134
|
|
|
126
135
|
```sh
|
|
127
136
|
npm install # devDeps 来自 npm registry
|
|
128
|
-
npm run typecheck # tsc
|
|
137
|
+
npm run typecheck # tsc 三面编译(host + client + client-test)
|
|
129
138
|
npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
|
|
130
139
|
npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
|
|
131
140
|
node scripts/verify-host.mjs # 端到端验证构建产物
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Client contract — rewind visibility
|
|
2
|
+
|
|
3
|
+
How third-party DOM plugins learn **which transcript rows a rewind withdrew**.
|
|
4
|
+
This is the only sanctioned answer to that question; anything not listed here
|
|
5
|
+
is internal and may change without notice.
|
|
6
|
+
|
|
7
|
+
## Stability tiers
|
|
8
|
+
|
|
9
|
+
| Channel | Stability | Consumers |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `/rewind` command `args` `@<seq>` | ✅ stable, semver-protected | machine |
|
|
12
|
+
| `outcome.sourceEventSeq` (marker log seq) | ✅ stable, semver-protected | machine |
|
|
13
|
+
| `data-dsh-rewind-hidden` attribute | ✅ stable name; observational only | DOM plugins |
|
|
14
|
+
| `outcome.text` | ❌ **not** stable — human copy, never parse | — |
|
|
15
|
+
|
|
16
|
+
## The sanctioned machine channel
|
|
17
|
+
|
|
18
|
+
`dsh-rewind-plugin/client` exports the pure, locale-independent computation
|
|
19
|
+
the plugin itself uses (it never reads the DOM or host copy):
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
23
|
+
|
|
24
|
+
const chat = session.getSnapshot().chat
|
|
25
|
+
const hidden = hiddenSeqsOf(chat as HiddenChat) // Set<number> of anchor seqs
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`hiddenSeqsOf` hides every internal probe row (`preview` / `__candidates`),
|
|
29
|
+
every successful executed `/rewind` row, and every message inside its
|
|
30
|
+
`[target, marker]` span (each rewind cuts one span; spans stay separate).
|
|
31
|
+
`targetSeqOfArgs` is exported for consumers that only need the target from a
|
|
32
|
+
command's `args`. Reuse these instead of re-deriving the logic (cf.
|
|
33
|
+
dsh-chat-timeline#6).
|
|
34
|
+
|
|
35
|
+
## DOM attribute
|
|
36
|
+
|
|
37
|
+
Each withdrawn row carries `data-dsh-rewind-hidden="true"` while hidden,
|
|
38
|
+
removed on un-hide. Contract:
|
|
39
|
+
|
|
40
|
+
- The **attribute name** is stable; treat the value as opaque.
|
|
41
|
+
- It is **observational only** — rewind hides via `style.display`; the
|
|
42
|
+
attribute records the cause, not the mechanism. Do not write it, and do
|
|
43
|
+
not expect it on rows outside rewind's control (third-party own elements).
|
|
44
|
+
|
|
45
|
+
## Explicit non-contract
|
|
46
|
+
|
|
47
|
+
`outcome.text` is human-facing copy. Its wording changes freely with
|
|
48
|
+
localization; parsing it is a bug. The `impact=<n>` trailer on preview text
|
|
49
|
+
is machine-readable and stable but outside this contract's scope.
|
|
50
|
+
|
|
51
|
+
## Maintenance rules
|
|
52
|
+
|
|
53
|
+
- Any change to the meaning of `@<seq>` / `sourceEventSeq` /
|
|
54
|
+
`data-dsh-rewind-hidden` updates this document in the same PR.
|
|
55
|
+
- Breaking a listed stability tier is a minor/major version bump.
|
|
56
|
+
- `scripts/build.mjs` asserts the export surface on every build.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 客户端契约 — 回退可见性
|
|
2
|
+
|
|
3
|
+
第三方 DOM 插件如何获知**哪些转录行被回退撤回**。这是该问题的唯一权威
|
|
4
|
+
答案;未在此列出的内容均属内部实现,可随时变更,恕不另行通知。
|
|
5
|
+
|
|
6
|
+
## 稳定性分级
|
|
7
|
+
|
|
8
|
+
| 通道 | 稳定性 | 适用方 |
|
|
9
|
+
| --- | --- | --- |
|
|
10
|
+
| `/rewind` 命令 `args` 中的 `@<seq>` | ✅ 稳定,受 semver 保护 | 机器 |
|
|
11
|
+
| `outcome.sourceEventSeq`(marker 日志 seq) | ✅ 稳定,受 semver 保护 | 机器 |
|
|
12
|
+
| `data-dsh-rewind-hidden` 属性 | ✅ 属性名稳定;仅观测性 | DOM 插件 |
|
|
13
|
+
| `outcome.text` | ❌ **不**稳定 — 人类可读文案,禁止解析 | — |
|
|
14
|
+
|
|
15
|
+
## 唯一机器通道
|
|
16
|
+
|
|
17
|
+
`dsh-rewind-plugin/client` 导出插件自身所用的纯函数计算,与本地化无关
|
|
18
|
+
(不读取 DOM,也不解析 host 文案):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { hiddenSeqsOf, type HiddenChat } from 'dsh-rewind-plugin/client'
|
|
22
|
+
|
|
23
|
+
const chat = session.getSnapshot().chat
|
|
24
|
+
const hidden = hiddenSeqsOf(chat as HiddenChat) // 被隐藏的 anchor seq 集合
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`hiddenSeqsOf` 隐藏所有内部探针行(`preview` / `__candidates`)、所有成功
|
|
28
|
+
执行的 `/rewind` 行,以及每条回退 `[target, marker]` 区间内的消息(每次
|
|
29
|
+
回退切一条区间,区间互不合并)。只需从命令 `args` 取 target 时,可用同样
|
|
30
|
+
导出的 `targetSeqOfArgs`。请复用这些实现,勿自行重写(参见
|
|
31
|
+
dsh-chat-timeline#6)。
|
|
32
|
+
|
|
33
|
+
## DOM 属性
|
|
34
|
+
|
|
35
|
+
每条被撤回的行在隐藏期间带有 `data-dsh-rewind-hidden="true"`,取消隐藏时
|
|
36
|
+
移除。契约如下:
|
|
37
|
+
|
|
38
|
+
- **属性名**稳定;值视为不透明。
|
|
39
|
+
- 仅**观测性**标记 — rewind 通过 `style.display` 隐藏,该属性记录的是
|
|
40
|
+
原因而非机制。不要写它;也不要在 rewind 控制范围之外(第三方自建元素)
|
|
41
|
+
期望它存在。
|
|
42
|
+
|
|
43
|
+
## 明确非契约
|
|
44
|
+
|
|
45
|
+
`outcome.text` 是面向人类用户的文案,措辞随本地化自由变更,解析它即视为
|
|
46
|
+
bug。preview 文案中的 `impact=<n>` 尾注是机器可读且稳定的,但不属于本
|
|
47
|
+
契约范围。
|
|
48
|
+
|
|
49
|
+
## 维护规则
|
|
50
|
+
|
|
51
|
+
- 任何改变 `@<seq>` / `sourceEventSeq` / `data-dsh-rewind-hidden` 语义的
|
|
52
|
+
改动,必须在同一 PR 中更新本文档。
|
|
53
|
+
- 破坏已承诺的稳定性分级属于 minor/major 版本变更。
|
|
54
|
+
- `scripts/build.mjs` 在每次构建时断言导出面。
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
> 进入「修复 / 钉住 / 记录」闭环。
|
|
7
|
+
>
|
|
8
|
+
> 针对版本:npm `@deepseek-ai/*@0.1.1-rc.2`(与 `package-lock.json` 一致);
|
|
9
|
+
> 源码参考:`oss/deepseek-harness` 本地 fork。
|
|
10
|
+
>
|
|
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`。
|
|
16
|
+
|
|
17
|
+
## 「完全适配」的可执行定义(不变量)
|
|
18
|
+
|
|
19
|
+
| 不变量 | 含义 | 探针位置 |
|
|
20
|
+
|---|---|---|
|
|
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-8、15 |
|
|
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 边界——**崩溃路径已根治**。
|
|
37
|
+
>
|
|
38
|
+
> **插件守卫(已尝试并回退)**:曾实现 `hasOpenStep` + `planRewind` 前置拒绝
|
|
39
|
+
> (`open-step`),但在**真实会话日志上产生误判**(正常回退被拒、GUI 验证功能缺失),
|
|
40
|
+
> 已 revert(`177ec14`)。结论:**插件不设守卫**,接受残余风险(运行中第三方插件
|
|
41
|
+
> 产生未闭合 step 时,rewind 可能破坏 /compact——该日志本身已异常,继续对话同样触发)。
|
|
42
|
+
> 根治方向在 harness(token-meter 对未闭合 step 的恢复),不在插件。
|
|
43
|
+
|
|
44
|
+
#### 未闭合 `step/start` 的具体触发情况(源码确认)
|
|
45
|
+
|
|
46
|
+
全仓**只有一处** append `step/start`:`packages/core/agent-loop/src/agent.ts:279`(官方包内
|
|
47
|
+
无其他生产者;`session/end-seed` 等修复只做 torn-write 截断,不处理逻辑未闭合)。
|
|
48
|
+
|
|
49
|
+
| # | 触发路径 | 现实性 | 依据 |
|
|
50
|
+
|---|---|---|---|
|
|
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` finally;write-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 不是唯一触发者)**:崩溃后 resume,agent-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 |
|
|
123
|
+
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
|
124
|
+
| 会话内核(append/surface/deriveMessages) | ✓ | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
|
125
|
+
| token-meter | ✓ | — | ✓ | — | ✓ | — | — | — |
|
|
126
|
+
| compaction(事务/命令/tool-pairing) | ✓ | — | — | — | ✓ | — | — | ✓ |
|
|
127
|
+
| session-stats / projection | — | — | ✓ | ✓ | — | — | — | — |
|
|
128
|
+
| session-title | — | — | — | ✓ | — | — | — | — |
|
|
129
|
+
| 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 行为差异。
|
|
@@ -12,6 +12,16 @@
|
|
|
12
12
|
- [commands.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/commands.md) — command registration (`ctx.commands.register`, `CommandInvocation`, `CommandResult`)
|
|
13
13
|
- [tools.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/tools.md) — tool execution seam (`tools/pre-execute` / `tools/post-execute`, `ToolExecution`)
|
|
14
14
|
- [session-query.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-query.md) — session query / `foldSurface` read-only interfaces
|
|
15
|
+
- [token-meter.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/token-meter.md) — replay-aware token meter (`TokenMeter.measure`, surface pricing)
|
|
16
|
+
- [compaction.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/compaction.md) — compaction seam / `compaction/*` events / `BasicCompactionEngine` (`summarize()` hook)
|
|
17
|
+
- [session-projection.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-projection.md) — projection registry (`SessionProjectionRegistry.snapshot/checkpoint`)
|
|
18
|
+
- [session-stats](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/session/session-stats) — whole-log stats projection (folds ALL events; rewind does not roll back counts)
|
|
19
|
+
- [session-title.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/session-title.md) — durable title state (`foldSessionTitle`)
|
|
20
|
+
- [goal.md](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/goal.md) — goal fold (`foldGoal`) and round admission
|
|
21
|
+
|
|
22
|
+
Compatibility probes against these subsystems (test-driven investigation, the
|
|
23
|
+
`compat-invariants` / `compat-interop` vitest suites + the `verify-host` real
|
|
24
|
+
`/compact` chain): [compat-audit.md](compat-audit.md).
|
|
15
25
|
|
|
16
26
|
Also under `docs/` at the repo root: `persistence-catalog.md` (full
|
|
17
27
|
`SessionEventMap`), `tool-catalog.md` (tool inventory), `config-catalog.md`
|
|
@@ -47,7 +57,6 @@ src/client/locales.ts zh / en copy (LocaleNamespaceMap)
|
|
|
47
57
|
src/client/styles.ts injected styles (dsh design tokens)
|
|
48
58
|
scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
49
59
|
scripts/verify-host.mjs end-to-end host verification (18 checks)
|
|
50
|
-
scripts/repair-markers.mjs offline marker-turn repair tool (ships as `dsh-rewind-repair`)
|
|
51
60
|
tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
|
|
52
61
|
docs/ maintainer docs: harness reference, troubleshooting, release steps
|
|
53
62
|
assets/screenshots/ UI screenshots
|
package/docs/release.md
CHANGED
|
@@ -88,3 +88,21 @@ git push origin main --tags # 触发 .github/workflows/publish.yml
|
|
|
88
88
|
**幂等**——已发布的版本会跳过。
|
|
89
89
|
- CI(`.github/workflows/ci.yml`)在每次 push / PR 跑相同检查,外加
|
|
90
90
|
`npm pack --dry-run` 校验 tarball 含 `lib/` 与 `LICENSE`。
|
|
91
|
+
|
|
92
|
+
## DSH 版本适配(peer 范围维护)
|
|
93
|
+
|
|
94
|
+
DSH 仍在 rc 阶段,npm 的 prerelease 匹配规则要求 peer 范围与宿主版本
|
|
95
|
+
**同 `[major, minor, patch]` 元组**才能匹配。因此 peerDependencies 采用
|
|
96
|
+
OR 并集覆盖 DSH 已发布的每个 rc 元组系列(如 `^0.1.0-rc.6 || ^0.1.1-rc.2`),
|
|
97
|
+
并随 DSH 发版追加。
|
|
98
|
+
|
|
99
|
+
- **何时需要更新**:仅当 DSH 发布新元组(`0.1.1 → 0.1.2 → 0.2.x`)时;
|
|
100
|
+
同元组内 rc 滚动(`0.1.1-rc.2 → rc.3`)无需动作。DSH 所有包同版本发布,
|
|
101
|
+
`npm view @deepseek-ai/dsh version` 即权威信号。
|
|
102
|
+
- **自动检测**:`node scripts/check-dsh-version.mjs` 对比 npm 最新版本与
|
|
103
|
+
peer 覆盖的元组,输出是否需追加(exit 0 无需动作,exit 1 需要)。
|
|
104
|
+
- **更新步骤**:给每个 `@deepseek-ai/dsh-*` peer 追加 `|| ^<新元组>-rc.<n>`
|
|
105
|
+
→ devDependencies 同步升到最新 → `npm install` → `npm run typecheck` /
|
|
106
|
+
`npm test` / `npm run verify:host` → 发版。
|
|
107
|
+
- **正式版后收敛**:DSH 发布 final 版本后,正式版不受 prerelease 元组规则
|
|
108
|
+
限制,peer 可收敛为稳定的 `^0.1.x` 单范围,此节即可删除。
|
package/docs/troubleshooting.md
CHANGED
|
@@ -4,36 +4,41 @@
|
|
|
4
4
|
|
|
5
5
|
## History load failure: `…turn-tail… received an update before its start Match`
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
conversation**: the marker's turn number collided with the next real turn's
|
|
9
|
-
`turn/start`, so reopening the session showed
|
|
7
|
+
Rewinds from `≤ 0.2.4` collided with the next real turn's `turn/start`, so reopening the session showed
|
|
10
8
|
|
|
11
9
|
```
|
|
12
10
|
Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
and the history vanished. Rewinds
|
|
16
|
-
collision, but **already-corrupted sessions need an offline repair** (the log is
|
|
17
|
-
append-only — it cannot be rewritten in memory).
|
|
18
|
-
|
|
19
|
-
The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
|
|
20
|
-
source checkout needed:
|
|
13
|
+
and the history vanished. Rewinds from `0.2.5` on no longer collide; already-corrupted sessions need an offline repair (the log is append-only). The repair tool (`dsh-rewind-repair`) is no longer shipped from v0.4.0 — install a pre-v0.4.0 release to get it (fully quit dsh web / host first, then):
|
|
21
14
|
|
|
22
15
|
```sh
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# 2. Run the offline repair (scans every session under ~/.dsh/sessions,
|
|
26
|
-
# rewriting each marker's turn back to the last started turn)
|
|
27
|
-
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
28
|
-
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
|
|
29
|
-
# 3. Restart dsh web — the repaired sessions load their history again
|
|
16
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
|
|
17
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # preview only
|
|
30
18
|
```
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
(
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
It only rewrites the marker events' `data.turn` (seqs, order, and zstd frame structure intact) and backs up the original file before writing — safe to run repeatedly. From a source checkout of a pre-v0.4.0 tag: `node scripts/repair-markers.mjs` (identical flags).
|
|
21
|
+
|
|
22
|
+
## Known compatibility boundaries
|
|
23
|
+
|
|
24
|
+
Behavioral notes verified by the compatibility probe suites
|
|
25
|
+
([compat-audit.md](compat-audit.md)) — none of these is a crash:
|
|
26
|
+
|
|
27
|
+
- **Session stats / telemetry do not roll back**: whole-log folds count the
|
|
28
|
+
withdrawn turns, and telemetry records the rewind's marker and ghost-step
|
|
29
|
+
frame under the reused turn. Expected whole-log semantics.
|
|
30
|
+
- **Withdrawn messages stay searchable and exported**: rewind cuts only the
|
|
31
|
+
model-visible surface; full-text search and `/export` still see the raw log.
|
|
32
|
+
- **Session titles may regenerate** after a rewind (title derives from the
|
|
33
|
+
current surface).
|
|
34
|
+
- **Files written by a cancelled tool call** (write happened, no result, no
|
|
35
|
+
snapshot commit) cannot be restored by "conversation and code".
|
|
36
|
+
|
|
37
|
+
**R-OPENSTEP** (harness-side, plugin does not guard): a session log carrying
|
|
38
|
+
an *unclosed* `step/start` (a crash before the agent loop's `finally` closed
|
|
39
|
+
the step) makes any later step activity break token-meter replay (and
|
|
40
|
+
`/compact`). Harness `0.1.1-rc.2` fixes the crash path on load
|
|
41
|
+
(`interruptedTurnClosers` closes leftover step/turn boundaries). A plugin-side
|
|
42
|
+
up-front rejection was implemented and **reverted** (false positives broke the
|
|
43
|
+
rewind feature on real session logs, `177ec14`); the residual runtime-produced
|
|
44
|
+
risk is accepted. Tracked in the compat-audit.
|
|
@@ -4,28 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 历史加载失败:`…turn-tail… received an update before its start Match`
|
|
6
6
|
|
|
7
|
-
0.2.4
|
|
8
|
-
与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
|
|
15
|
-
append-only 的,不能在内存中改写)。
|
|
16
|
-
|
|
17
|
-
修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
|
|
7
|
+
0.2.4 及之前版本的回退与下一条真实回合的 `turn/start` 撞号,重开会话时历史整段消失。0.2.5 起的新回退不再产生该冲突;**已损坏的旧会话需离线修复**(日志是 append-only 的)。修复工具(`dsh-rewind-repair`)v0.4.0 起不再随包提供——安装 v0.4.0 之前的版本即可获取(先完全退出 dsh web / host,然后):
|
|
18
8
|
|
|
19
9
|
```sh
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
23
|
-
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
|
|
24
|
-
# 3. 重启 dsh web,损坏的会话即可正常加载历史
|
|
10
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair
|
|
11
|
+
npm exec --yes --package=dsh-rewind-plugin@0.3.3 -- dsh-rewind-repair -- --dry-run # 只预览不写盘
|
|
25
12
|
```
|
|
26
13
|
|
|
27
|
-
|
|
28
|
-
源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
|
|
29
|
-
|
|
30
|
-
工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
|
|
31
|
-
改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
|
|
14
|
+
工具只改写标记事件的 `data.turn`(保持 seq / 顺序 / zstd 帧结构不变),改前自动备份原文件——可安全重复运行。源码方式:v0.4.0 之前的 tag 下 `node scripts/repair-markers.mjs`(参数相同)。
|
package/lib/client.js
CHANGED
|
@@ -27,8 +27,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
27
27
|
var index_exports = {};
|
|
28
28
|
__export(index_exports, {
|
|
29
29
|
apply: () => apply,
|
|
30
|
+
hiddenSeqsOf: () => hiddenSeqsOf,
|
|
30
31
|
inject: () => inject,
|
|
31
|
-
name: () => name
|
|
32
|
+
name: () => name,
|
|
33
|
+
targetSeqOfArgs: () => targetSeqOfArgs
|
|
32
34
|
});
|
|
33
35
|
module.exports = __toCommonJS(index_exports);
|
|
34
36
|
|
|
@@ -575,6 +577,8 @@ function openPopover(opts) {
|
|
|
575
577
|
];
|
|
576
578
|
if (bothState.state === "noChanges") {
|
|
577
579
|
children.push(el("div", CLASS.popoverImpact, t("popover.noChanges")));
|
|
580
|
+
} else if (bothState.state === "error") {
|
|
581
|
+
children.push(el("div", CLASS.popoverImpact, t("popover.impact.failed", { message: bothState.message })));
|
|
578
582
|
} else {
|
|
579
583
|
const option = modeOption(
|
|
580
584
|
t("popover.both"),
|
|
@@ -628,6 +632,8 @@ function openPopover(opts) {
|
|
|
628
632
|
impactOutcome = outcome;
|
|
629
633
|
if (outcome !== null && outcome.kind === "success") {
|
|
630
634
|
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
635
|
+
} else if (outcome !== null && outcome.kind === "error") {
|
|
636
|
+
bothState = { state: "error", message: outcome.text ?? "unknown error" };
|
|
631
637
|
}
|
|
632
638
|
renderModes();
|
|
633
639
|
shell.position();
|
|
@@ -695,12 +701,31 @@ async function runRewindAndFill(session, seq, mode, currentSessionId) {
|
|
|
695
701
|
const result = await session.command(`/rewind @${seq} ${mode}`);
|
|
696
702
|
if (!result.ok || result.value?.matched !== true) return;
|
|
697
703
|
const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
698
|
-
if (outcome === null
|
|
704
|
+
if (outcome === null) return;
|
|
705
|
+
if (outcome.kind !== "success") {
|
|
706
|
+
showHint(outcome.text ?? "rewind failed");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
699
709
|
if (currentSessionId() !== session.sessionId) return;
|
|
700
710
|
const text = userTextAt(session, seq);
|
|
701
711
|
if (text === void 0 || text === "") return;
|
|
702
712
|
fillComposer(text);
|
|
703
713
|
}
|
|
714
|
+
function showHint(text) {
|
|
715
|
+
const textarea = document.querySelector(COMPOSER_SELECTOR);
|
|
716
|
+
const hint = document.createElement("div");
|
|
717
|
+
hint.className = CLASS.guardHint;
|
|
718
|
+
hint.setAttribute("role", "status");
|
|
719
|
+
hint.textContent = text;
|
|
720
|
+
document.body.appendChild(hint);
|
|
721
|
+
if (textarea !== null) {
|
|
722
|
+
const card = textarea.closest("[data-composer-card]");
|
|
723
|
+
const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
|
|
724
|
+
hint.style.left = `${Math.round(rect.left)}px`;
|
|
725
|
+
hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
|
|
726
|
+
}
|
|
727
|
+
window.setTimeout(() => hint.remove(), 3200);
|
|
728
|
+
}
|
|
704
729
|
var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
705
730
|
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
|
|
706
731
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
package/lib/index.js
CHANGED
|
@@ -90,6 +90,15 @@ function markerTurnOf(events) {
|
|
|
90
90
|
}
|
|
91
91
|
return lastStarted;
|
|
92
92
|
}
|
|
93
|
+
function markerStepOf(events, turn) {
|
|
94
|
+
let lastStarted = 0;
|
|
95
|
+
for (const event of events) {
|
|
96
|
+
if (event.type === "step/start" && event.data.turn === turn && event.data.step > lastStarted) {
|
|
97
|
+
lastStarted = event.data.step;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return lastStarted + 1;
|
|
101
|
+
}
|
|
93
102
|
function isUserMessageEvent(event) {
|
|
94
103
|
return event.type === "user/message";
|
|
95
104
|
}
|
|
@@ -686,10 +695,19 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
686
695
|
const marker = buildMarker();
|
|
687
696
|
let event;
|
|
688
697
|
try {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
698
|
+
const turn = markerTurnOf(agent.session.events);
|
|
699
|
+
const step = markerStepOf(agent.session.events, turn);
|
|
700
|
+
agent.session.append("step/start", { turn, step });
|
|
701
|
+
try {
|
|
702
|
+
event = agent.session.append("assistant/message", { turn, step, message: marker }, {
|
|
703
|
+
surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
|
|
704
|
+
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
agent.session.append("step/end", { turn, step });
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
agent.session.append("step/end", { turn, step });
|
|
693
711
|
} catch (error) {
|
|
694
712
|
return {
|
|
695
713
|
kind: "error",
|
|
@@ -35,3 +35,8 @@ export declare const inject: string[];
|
|
|
35
35
|
* @param ctx - client root context carrying `slots`, `sessions`, `locale` and `commandUi`.
|
|
36
36
|
*/
|
|
37
37
|
export declare function apply(ctx: ClientContext): void;
|
|
38
|
+
/**
|
|
39
|
+
* Public contract — rewind visibility. Stable, semver-protected; the rest of
|
|
40
|
+
* this module is internal. See `docs/client-contract.md`.
|
|
41
|
+
*/
|
|
42
|
+
export { hiddenSeqsOf, targetSeqOfArgs, type HiddenChat } from './hidden.ts';
|
package/lib/types/rewind.d.ts
CHANGED
|
@@ -9,6 +9,24 @@
|
|
|
9
9
|
* transcript) is untouched; only the model-visible surface is cut, so the
|
|
10
10
|
* next request derives its context from the target message onward.
|
|
11
11
|
*
|
|
12
|
+
* Marker shape (v0.3.4+): the marker is an EMPTY `assistant/message` with a
|
|
13
|
+
* replace `surfaceOp`, wrapped in its own step frame —
|
|
14
|
+
*
|
|
15
|
+
* step/start (turn, step) → assistant/message (marker) → step/end (turn, step)
|
|
16
|
+
*
|
|
17
|
+
* The step frame exists because the harness token-meter replays the log and
|
|
18
|
+
* requires every `assistant/message` to sit inside an OPEN step whose
|
|
19
|
+
* `(turn, step)` matches exactly (`token meter: assistant/message at seq N
|
|
20
|
+
* has no matching step/start event` otherwise) — a bare marker appended while
|
|
21
|
+
* idle (every step already closed) makes every later measure() call throw,
|
|
22
|
+
* which disables /compact and automatic compaction. The frame's `turn` is the
|
|
23
|
+
* LAST STARTED turn (`markerTurnOf`) and `step` is that turn's next unused
|
|
24
|
+
* step number (`markerStepOf`): never a reused one, or the client
|
|
25
|
+
* conversation assembler sees a duplicate `step/start` and rejects the log
|
|
26
|
+
* with "received more than one start Match". The agent loop numbers its own
|
|
27
|
+
* steps from memory (each new turn restarts at 1), so the ghost step can
|
|
28
|
+
* never collide with a future real step.
|
|
29
|
+
*
|
|
12
30
|
* @module dsh-rewind/rewind
|
|
13
31
|
*/
|
|
14
32
|
import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session';
|
|
@@ -89,6 +107,25 @@ export declare const DEFAULT_CANDIDATE_LIMIT = 50;
|
|
|
89
107
|
* @returns a turn number the harness can never reuse for a future `turn/start`.
|
|
90
108
|
*/
|
|
91
109
|
export declare function markerTurnOf(events: readonly SessionEvent[]): number;
|
|
110
|
+
/**
|
|
111
|
+
* Step number for the rewind marker's ghost step frame.
|
|
112
|
+
*
|
|
113
|
+
* The marker's `assistant/message` must be wrapped in `step/start` …
|
|
114
|
+
* `step/end` of the SAME `(turn, step)` so the harness token-meter replay
|
|
115
|
+
* accepts it (see the module doc). The step number MUST be a step this turn
|
|
116
|
+
* has never started: the client conversation assembler treats `step/start`
|
|
117
|
+
* as the start of an `assistant-step` context keyed `turn:step`, so reusing
|
|
118
|
+
* an already-started step number makes the log replay throw "received more
|
|
119
|
+
* than one start Match" and the history disappears from the UI. Reusing
|
|
120
|
+
* `lastStep + 1` is always safe: the harness numbers a turn's steps from
|
|
121
|
+
* memory (each new turn restarts at 1), so the ghost step can never collide
|
|
122
|
+
* with a future real step of this turn.
|
|
123
|
+
*
|
|
124
|
+
* @param events - the full session event log.
|
|
125
|
+
* @param turn - the marker's turn (normally `markerTurnOf(events)`).
|
|
126
|
+
* @returns the smallest step number this turn has never started (≥ 1).
|
|
127
|
+
*/
|
|
128
|
+
export declare function markerStepOf(events: readonly SessionEvent[], turn: number): number;
|
|
92
129
|
/** Narrow an event to a user message. */
|
|
93
130
|
export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
94
131
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -23,9 +23,6 @@
|
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "lib/index.js",
|
|
25
25
|
"types": "lib/types/index.d.ts",
|
|
26
|
-
"bin": {
|
|
27
|
-
"dsh-rewind-repair": "scripts/repair-markers.mjs"
|
|
28
|
-
},
|
|
29
26
|
"exports": {
|
|
30
27
|
".": {
|
|
31
28
|
"types": "./lib/types/index.d.ts",
|
|
@@ -44,8 +41,7 @@
|
|
|
44
41
|
"README.zh.md",
|
|
45
42
|
"docs",
|
|
46
43
|
"assets",
|
|
47
|
-
"LICENSE"
|
|
48
|
-
"scripts/repair-markers.mjs"
|
|
44
|
+
"LICENSE"
|
|
49
45
|
],
|
|
50
46
|
"dsh": {
|
|
51
47
|
"bundle": {
|
|
@@ -63,7 +59,7 @@
|
|
|
63
59
|
},
|
|
64
60
|
"scripts": {
|
|
65
61
|
"build": "node scripts/build.mjs",
|
|
66
|
-
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
|
|
62
|
+
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json && tsc --noEmit -p tsconfig.client-test.json",
|
|
67
63
|
"test": "vitest run",
|
|
68
64
|
"verify:host": "node scripts/verify-host.mjs",
|
|
69
65
|
"prepare": "npm run build"
|
|
@@ -73,17 +69,17 @@
|
|
|
73
69
|
},
|
|
74
70
|
"peerDependencies": {
|
|
75
71
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
76
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
77
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.6",
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
80
|
-
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
|
|
81
|
-
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
|
|
82
|
-
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
83
|
-
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
|
|
84
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
85
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
86
|
-
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
|
|
72
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-commands": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.1.1-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8 || ^0.1.1-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.1-rc.2"
|
|
87
83
|
},
|
|
88
84
|
"peerDependenciesMeta": {
|
|
89
85
|
"@deepseek-ai/cordis": {
|
|
@@ -125,19 +121,27 @@
|
|
|
125
121
|
},
|
|
126
122
|
"devDependencies": {
|
|
127
123
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
128
|
-
"@deepseek-ai/dsh-agent": "^0.1.
|
|
129
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
130
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.
|
|
131
|
-
"@deepseek-ai/dsh-client-ui-commands": "^0.1.
|
|
132
|
-
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.
|
|
133
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
134
|
-
"@deepseek-ai/dsh-
|
|
135
|
-
"@deepseek-ai/dsh-
|
|
136
|
-
"@deepseek-ai/dsh-
|
|
137
|
-
"@deepseek-ai/dsh-
|
|
138
|
-
"@deepseek-ai/dsh-
|
|
139
|
-
"@deepseek-ai/dsh-
|
|
140
|
-
"@deepseek-ai/dsh-
|
|
124
|
+
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
|
|
125
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
126
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
127
|
+
"@deepseek-ai/dsh-client-ui-commands": "^0.1.1-rc.2",
|
|
128
|
+
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.1-rc.2",
|
|
129
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
|
|
130
|
+
"@deepseek-ai/dsh-command-compact": "^0.1.1-rc.2",
|
|
131
|
+
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
132
|
+
"@deepseek-ai/dsh-compaction": "^0.1.1-rc.2",
|
|
133
|
+
"@deepseek-ai/dsh-compaction-basic": "^0.1.1-rc.2",
|
|
134
|
+
"@deepseek-ai/dsh-fs": "^0.1.1-rc.2",
|
|
135
|
+
"@deepseek-ai/dsh-goal": "^0.1.1-rc.2",
|
|
136
|
+
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
137
|
+
"@deepseek-ai/dsh-sandbox": "^0.1.1-rc.2",
|
|
138
|
+
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
|
|
139
|
+
"@deepseek-ai/dsh-session-projection": "^0.1.1-rc.2",
|
|
140
|
+
"@deepseek-ai/dsh-session-stats": "^0.1.1-rc.2",
|
|
141
|
+
"@deepseek-ai/dsh-session-title": "^0.1.1-rc.2",
|
|
142
|
+
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
143
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.1-rc.2",
|
|
144
|
+
"@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
|
|
141
145
|
"@types/node": "^24.0.0",
|
|
142
146
|
"@types/react": "^18.3.31",
|
|
143
147
|
"@types/react-dom": "^18.3.7",
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Offline repair for sessions corrupted by the ≤ 0.2.4 marker turn-number bug.
|
|
4
|
-
*
|
|
5
|
-
* Symptom: reopening a session shows
|
|
6
|
-
* Failed to load history: conversation Context …:turn-tail… received an
|
|
7
|
-
* update before its start Match (internal)
|
|
8
|
-
* and the history disappears. Root cause: the rewind marker was numbered
|
|
9
|
-
* `lastTurn + 1`, which is exactly how the harness numbers its NEXT real turn —
|
|
10
|
-
* so the log holds an `assistant/message` (the marker) BEFORE the `turn/start`
|
|
11
|
-
* of the same turn, and the client conversation-context builder rejects that
|
|
12
|
-
* ordering.
|
|
13
|
-
*
|
|
14
|
-
* This script rewrites ONLY the `data.turn` of `dsh-rewind` empty-marker events
|
|
15
|
-
* to the last turn/start that precedes them (a turn the harness has already
|
|
16
|
-
* consumed and can never reuse). It preserves:
|
|
17
|
-
* - every other event byte-for-byte (seqs, times, surface metadata, …),
|
|
18
|
-
* - the JSONL record order and line structure,
|
|
19
|
-
* - the zstd multi-frame structure the persistence backend expects
|
|
20
|
-
* (each frame is decoded, fixed, and re-compressed independently, with the
|
|
21
|
-
* "last started turn" state threaded across frames).
|
|
22
|
-
*
|
|
23
|
-
* The original file is backed up to `session.jsonl.zstd.bak-<timestamp>` before
|
|
24
|
-
* the first write. Run with the host fully stopped (a resident session would
|
|
25
|
-
* overwrite the repaired file at its next checkpoint), then restart dsh web.
|
|
26
|
-
*
|
|
27
|
-
* Usage:
|
|
28
|
-
* node scripts/repair-markers.mjs # scan ~/.dsh/sessions
|
|
29
|
-
* node scripts/repair-markers.mjs --dry-run # report only, no writes
|
|
30
|
-
* node scripts/repair-markers.mjs --dir <sessions root>
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { readdirSync, readFileSync, writeFileSync, copyFileSync, statSync, existsSync } from 'node:fs'
|
|
34
|
-
import { join } from 'node:path'
|
|
35
|
-
import { homedir } from 'node:os'
|
|
36
|
-
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
37
|
-
|
|
38
|
-
const ZSTD_MAGIC = 0xfd2fb528
|
|
39
|
-
const MARKER_SOURCE = { provider: 'dsh-rewind', model: 'rewind-marker' }
|
|
40
|
-
const DEFAULT_ROOT = join(homedir(), '.dsh', 'sessions')
|
|
41
|
-
|
|
42
|
-
/** @param {Buffer} buffer @returns {{start:number,end:number}[]} */
|
|
43
|
-
function scanFrames(buffer) {
|
|
44
|
-
const frames = []
|
|
45
|
-
let offset = 0
|
|
46
|
-
while (offset < buffer.length) {
|
|
47
|
-
const start = offset
|
|
48
|
-
if (buffer.length - offset < 4 || buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break
|
|
49
|
-
offset += 4
|
|
50
|
-
const next = buffer.indexOf(Buffer.from([0x28, 0xb5, 0x2f, 0xfd]), offset)
|
|
51
|
-
frames.push(next === -1 ? { start, end: buffer.length } : { start, end: next })
|
|
52
|
-
offset = next === -1 ? buffer.length : next
|
|
53
|
-
}
|
|
54
|
-
return frames
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Rewrite marker turn numbers in ONE frame's JSONL text. Line-preserving: the
|
|
59
|
-
* fix never adds or removes lines, only rewrites a marker line's turn. State
|
|
60
|
-
* (`lastStarted`) is threaded across frames, because a marker in a later frame
|
|
61
|
-
* must know the turn/start events of earlier frames.
|
|
62
|
-
* @param {string} text - this frame's JSONL text.
|
|
63
|
-
* @param {{lastStarted:number}} state - running "last started turn" state.
|
|
64
|
-
* @returns {{text:string, changed:number}} the fixed text (identical when
|
|
65
|
-
* nothing changed) and the number of rewritten markers in this frame.
|
|
66
|
-
*/
|
|
67
|
-
function fixMarkerTurns(text, state) {
|
|
68
|
-
const lines = text.split('\n')
|
|
69
|
-
let changed = 0
|
|
70
|
-
const out = []
|
|
71
|
-
for (const line of lines) {
|
|
72
|
-
if (line.trim() === '') {
|
|
73
|
-
out.push(line)
|
|
74
|
-
continue
|
|
75
|
-
}
|
|
76
|
-
let record
|
|
77
|
-
try {
|
|
78
|
-
record = JSON.parse(line)
|
|
79
|
-
} catch {
|
|
80
|
-
out.push(line) // keep unknown/torn lines verbatim
|
|
81
|
-
continue
|
|
82
|
-
}
|
|
83
|
-
if (record && record.type === 'turn/start' && Number.isSafeInteger(record.data?.turn)) {
|
|
84
|
-
if (record.data.turn > state.lastStarted) state.lastStarted = record.data.turn
|
|
85
|
-
out.push(line)
|
|
86
|
-
continue
|
|
87
|
-
}
|
|
88
|
-
const isMarker =
|
|
89
|
-
record?.type === 'assistant/message' &&
|
|
90
|
-
record.data?.message?.source?.provider === MARKER_SOURCE.provider &&
|
|
91
|
-
record.data?.message?.source?.model === MARKER_SOURCE.model
|
|
92
|
-
if (!isMarker) {
|
|
93
|
-
out.push(line)
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
const badTurn = record.data.turn
|
|
97
|
-
if (badTurn === state.lastStarted) {
|
|
98
|
-
out.push(line) // already safe (post-0.2.5 marker) — idempotent
|
|
99
|
-
continue
|
|
100
|
-
}
|
|
101
|
-
changed += 1
|
|
102
|
-
out.push(JSON.stringify({ ...record, data: { ...record.data, turn: state.lastStarted } }))
|
|
103
|
-
}
|
|
104
|
-
return { text: out.join('\n'), changed }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Repair one session artifact file. @returns {string} a human summary line. */
|
|
108
|
-
function repairFile(file, dryRun) {
|
|
109
|
-
const original = readFileSync(file)
|
|
110
|
-
const frames = scanFrames(original)
|
|
111
|
-
const state = { lastStarted: 0 }
|
|
112
|
-
const fixedTexts = []
|
|
113
|
-
let changed = 0
|
|
114
|
-
for (const frame of frames) {
|
|
115
|
-
const frameText = zstdDecompressSync(original.subarray(frame.start, frame.end)).toString('utf8')
|
|
116
|
-
const result = fixMarkerTurns(frameText, state)
|
|
117
|
-
changed += result.changed
|
|
118
|
-
fixedTexts.push(result.text)
|
|
119
|
-
}
|
|
120
|
-
if (changed === 0) return ` ok (no marker fix needed): ${file}`
|
|
121
|
-
if (dryRun) return ` would fix ${changed} marker(s): ${file}`
|
|
122
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
123
|
-
copyFileSync(file, `${file}.bak-${stamp}`)
|
|
124
|
-
// Preserve the zstd frame structure: one re-compressed frame per original
|
|
125
|
-
// frame, in order.
|
|
126
|
-
writeFileSync(file, Buffer.concat(fixedTexts.map(text => zstdCompressSync(Buffer.from(text)))))
|
|
127
|
-
return ` fixed ${changed} marker(s): ${file} (backup: ${file}.bak-${stamp})`
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function collectSessions(root) {
|
|
131
|
-
const files = []
|
|
132
|
-
for (const project of readdirSync(root)) {
|
|
133
|
-
const projectDir = join(root, project)
|
|
134
|
-
let stat
|
|
135
|
-
try {
|
|
136
|
-
stat = statSync(projectDir)
|
|
137
|
-
} catch {
|
|
138
|
-
continue
|
|
139
|
-
}
|
|
140
|
-
if (!stat.isDirectory()) continue
|
|
141
|
-
for (const entry of readdirSync(projectDir)) {
|
|
142
|
-
const file = join(projectDir, entry, 'session.jsonl.zstd')
|
|
143
|
-
if (existsSync(file)) files.push(file)
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return files
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const args = process.argv.slice(2)
|
|
150
|
-
const dryRun = args.includes('--dry-run')
|
|
151
|
-
const dirIndex = args.indexOf('--dir')
|
|
152
|
-
const root = dirIndex !== -1 && args[dirIndex + 1] !== undefined ? args[dirIndex + 1] : DEFAULT_ROOT
|
|
153
|
-
|
|
154
|
-
if (!existsSync(root)) {
|
|
155
|
-
console.error(`sessions root not found: ${root}`)
|
|
156
|
-
process.exit(1)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
console.log(`${dryRun ? '[dry-run] ' : ''}scanning ${root}`)
|
|
160
|
-
const files = collectSessions(root)
|
|
161
|
-
console.log(`${files.length} session artifact(s) found`)
|
|
162
|
-
let fixedCount = 0
|
|
163
|
-
for (const file of files) {
|
|
164
|
-
try {
|
|
165
|
-
const line = repairFile(file, dryRun)
|
|
166
|
-
if (!line.includes('no marker fix needed')) {
|
|
167
|
-
console.log(line)
|
|
168
|
-
fixedCount += 1
|
|
169
|
-
}
|
|
170
|
-
} catch (error) {
|
|
171
|
-
console.error(` ERROR ${file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
console.log(fixedCount === 0
|
|
175
|
-
? 'no corrupted sessions found — nothing to do'
|
|
176
|
-
: `${dryRun ? 'would repair' : 'repaired'} ${fixedCount} session(s). ${dryRun ? '' : 'Restart dsh web to reload the repaired histories.'}`)
|