dsh-rewind-plugin 0.3.2 → 0.4.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.md +12 -3
- package/README.zh.md +13 -4
- package/docs/client-contract.md +56 -0
- package/docs/client-contract.zh.md +54 -0
- package/docs/harness-reference.md +0 -1
- package/docs/troubleshooting.md +5 -24
- package/docs/troubleshooting.zh.md +4 -21
- package/lib/client.js +263 -49
- package/lib/index.js +29 -5
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/pending.d.ts +54 -0
- package/lib/types/client/popover.d.ts +12 -3
- package/lib/types/client/portals.d.ts +22 -0
- package/lib/types/rewind.d.ts +37 -0
- package/package.json +5 -7
- 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,7 +134,7 @@ 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
|
|
137
|
+
npm run typecheck # tsc on all three surfaces (host + client + client-test)
|
|
129
138
|
npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
|
|
130
139
|
npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
131
140
|
node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
|
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` 在每次构建时断言导出面。
|
|
@@ -47,7 +47,6 @@ src/client/locales.ts zh / en copy (LocaleNamespaceMap)
|
|
|
47
47
|
src/client/styles.ts injected styles (dsh design tokens)
|
|
48
48
|
scripts/build.mjs esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
|
|
49
49
|
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
50
|
tests/ vitest suites (rewind / snapshot / hidden / session-cwd / integration)
|
|
52
51
|
docs/ maintainer docs: harness reference, troubleshooting, release steps
|
|
53
52
|
assets/screenshots/ UI screenshots
|
package/docs/troubleshooting.md
CHANGED
|
@@ -4,36 +4,17 @@
|
|
|
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
|
-
`dsh-rewind-repair` directly; from a source checkout the same tool is
|
|
34
|
-
`node scripts/repair-markers.mjs` (identical flags).
|
|
35
|
-
|
|
36
|
-
The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
|
|
37
|
-
(keeping seqs, order, and the zstd frame structure intact), backs up the original
|
|
38
|
-
file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
|
|
39
|
-
any other event — safe to run repeatedly.
|
|
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).
|
|
@@ -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
|
|
|
@@ -467,9 +469,96 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
467
469
|
impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
|
|
468
470
|
});
|
|
469
471
|
}
|
|
472
|
+
function mountShell(root, anchor, onKeyDown) {
|
|
473
|
+
const position = () => {
|
|
474
|
+
const rect = anchor.getBoundingClientRect();
|
|
475
|
+
const gap = 4;
|
|
476
|
+
const height = root.offsetHeight;
|
|
477
|
+
const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
|
|
478
|
+
root.style.top = `${Math.round(top)}px`;
|
|
479
|
+
root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
|
|
480
|
+
};
|
|
481
|
+
const onPointerDown = (event) => {
|
|
482
|
+
const target = event.target;
|
|
483
|
+
if (root.contains(target) || anchor.contains(target)) return;
|
|
484
|
+
closePopover();
|
|
485
|
+
};
|
|
486
|
+
const deferred = setTimeout(() => {
|
|
487
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
488
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
489
|
+
}, 0);
|
|
490
|
+
const dispose = () => {
|
|
491
|
+
clearTimeout(deferred);
|
|
492
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
493
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
494
|
+
};
|
|
495
|
+
document.body.append(root);
|
|
496
|
+
position();
|
|
497
|
+
return { position, dispose };
|
|
498
|
+
}
|
|
499
|
+
function openRetractPopover(opts) {
|
|
500
|
+
closePopover();
|
|
501
|
+
const { preview, anchor, t, retract, onRetract } = opts;
|
|
502
|
+
if (retract === void 0 || onRetract === void 0) return;
|
|
503
|
+
const root = el("div", CLASS.popover);
|
|
504
|
+
root.setAttribute("role", "dialog");
|
|
505
|
+
root.setAttribute("aria-label", t("popover.retract.title"));
|
|
506
|
+
const onKeyDown = (event) => {
|
|
507
|
+
if (event.key === "ArrowDown") {
|
|
508
|
+
event.preventDefault();
|
|
509
|
+
event.stopPropagation();
|
|
510
|
+
moveFocus(root, 1);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (event.key === "ArrowUp") {
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
event.stopPropagation();
|
|
516
|
+
moveFocus(root, -1);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (event.key === "Escape") {
|
|
520
|
+
event.preventDefault();
|
|
521
|
+
event.stopPropagation();
|
|
522
|
+
closePopover();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const previewText = preview.length > 0 ? preview : t("popover.noText");
|
|
526
|
+
const actions = el("div", CLASS.popoverActions);
|
|
527
|
+
const confirm = document.createElement("button");
|
|
528
|
+
confirm.type = "button";
|
|
529
|
+
confirm.className = CLASS.popoverPrimary;
|
|
530
|
+
confirm.textContent = t("popover.retract.confirm");
|
|
531
|
+
confirm.addEventListener("click", () => {
|
|
532
|
+
closePopover();
|
|
533
|
+
onRetract();
|
|
534
|
+
});
|
|
535
|
+
const cancel = document.createElement("button");
|
|
536
|
+
cancel.type = "button";
|
|
537
|
+
cancel.className = CLASS.popoverGhost;
|
|
538
|
+
cancel.textContent = t("popover.cancel");
|
|
539
|
+
cancel.addEventListener("click", closePopover);
|
|
540
|
+
actions.append(confirm, cancel);
|
|
541
|
+
root.replaceChildren(
|
|
542
|
+
el("div", CLASS.popoverTitle, t("popover.retract.title")),
|
|
543
|
+
el("div", CLASS.popoverTarget, t("popover.retract.target", { preview: previewText })),
|
|
544
|
+
el("div", CLASS.popoverImpact, t("popover.retract.hint")),
|
|
545
|
+
actions
|
|
546
|
+
);
|
|
547
|
+
const shell = mountShell(root, anchor, onKeyDown);
|
|
548
|
+
popoverEl = root;
|
|
549
|
+
disposeOutside = shell.dispose;
|
|
550
|
+
focusFirst(root);
|
|
551
|
+
}
|
|
470
552
|
function openPopover(opts) {
|
|
471
553
|
closePopover();
|
|
554
|
+
if (opts.retract !== void 0) {
|
|
555
|
+
openRetractPopover(opts);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
472
558
|
const { session, seq, time, preview, anchor, t } = opts;
|
|
559
|
+
const onRewind = opts.onRewind;
|
|
560
|
+
if (seq === void 0 || time === void 0 || onRewind === void 0) return;
|
|
561
|
+
const durableOpts = { session, seq, time, preview, anchor, t, onRewind };
|
|
473
562
|
const root = el("div", CLASS.popover);
|
|
474
563
|
root.setAttribute("role", "dialog");
|
|
475
564
|
root.setAttribute("aria-label", t("popover.title"));
|
|
@@ -483,11 +572,13 @@ function openPopover(opts) {
|
|
|
483
572
|
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
484
573
|
modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
|
|
485
574
|
closePopover();
|
|
486
|
-
|
|
575
|
+
durableOpts.onRewind("chat");
|
|
487
576
|
})
|
|
488
577
|
];
|
|
489
578
|
if (bothState.state === "noChanges") {
|
|
490
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 })));
|
|
491
582
|
} else {
|
|
492
583
|
const option = modeOption(
|
|
493
584
|
t("popover.both"),
|
|
@@ -510,38 +601,7 @@ function openPopover(opts) {
|
|
|
510
601
|
};
|
|
511
602
|
const renderImpact = () => {
|
|
512
603
|
step = "impact";
|
|
513
|
-
renderImpactStep(root,
|
|
514
|
-
};
|
|
515
|
-
const position = () => {
|
|
516
|
-
const rect = anchor.getBoundingClientRect();
|
|
517
|
-
const gap = 4;
|
|
518
|
-
const height = root.offsetHeight;
|
|
519
|
-
const top = rect.bottom + gap + height <= window.innerHeight - 8 ? rect.bottom + gap : Math.max(8, rect.top - gap - height);
|
|
520
|
-
root.style.top = `${Math.round(top)}px`;
|
|
521
|
-
root.style.left = `${Math.round(Math.min(rect.right, window.innerWidth - 8 - root.offsetWidth))}px`;
|
|
522
|
-
};
|
|
523
|
-
renderModes();
|
|
524
|
-
document.body.append(root);
|
|
525
|
-
position();
|
|
526
|
-
focusFirst(root);
|
|
527
|
-
void (async () => {
|
|
528
|
-
const outcome = await previewImpact(session, seq);
|
|
529
|
-
impactOutcome = outcome;
|
|
530
|
-
if (outcome !== null && outcome.kind === "success") {
|
|
531
|
-
bothState = { state: hasFileImpact(outcome.text) ? "hasChanges" : "noChanges" };
|
|
532
|
-
}
|
|
533
|
-
renderModes();
|
|
534
|
-
position();
|
|
535
|
-
})().catch(() => {
|
|
536
|
-
bothState = { state: "hasChanges" };
|
|
537
|
-
renderModes();
|
|
538
|
-
position();
|
|
539
|
-
});
|
|
540
|
-
popoverEl = root;
|
|
541
|
-
const onPointerDown = (event) => {
|
|
542
|
-
const target = event.target;
|
|
543
|
-
if (root.contains(target) || anchor.contains(target)) return;
|
|
544
|
-
closePopover();
|
|
604
|
+
renderImpactStep(root, durableOpts, renderModes, impactOutcome);
|
|
545
605
|
};
|
|
546
606
|
const onKeyDown = (event) => {
|
|
547
607
|
if (event.key === "ArrowDown") {
|
|
@@ -563,20 +623,52 @@ function openPopover(opts) {
|
|
|
563
623
|
else closePopover();
|
|
564
624
|
}
|
|
565
625
|
};
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
626
|
+
renderModes();
|
|
627
|
+
const shell = mountShell(root, anchor, onKeyDown);
|
|
628
|
+
popoverEl = root;
|
|
629
|
+
disposeOutside = shell.dispose;
|
|
630
|
+
void (async () => {
|
|
631
|
+
const outcome = await previewImpact(session, seq);
|
|
632
|
+
impactOutcome = outcome;
|
|
633
|
+
if (outcome !== null && outcome.kind === "success") {
|
|
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" };
|
|
637
|
+
}
|
|
638
|
+
renderModes();
|
|
639
|
+
shell.position();
|
|
640
|
+
})().catch(() => {
|
|
641
|
+
bothState = { state: "hasChanges" };
|
|
642
|
+
renderModes();
|
|
643
|
+
shell.position();
|
|
644
|
+
});
|
|
575
645
|
}
|
|
576
646
|
|
|
577
647
|
// src/client/portals.tsx
|
|
578
648
|
var import_react = require("react");
|
|
579
649
|
var import_react_dom = require("react-dom");
|
|
650
|
+
|
|
651
|
+
// src/client/pending.ts
|
|
652
|
+
function matchPendingRows(rows, steering) {
|
|
653
|
+
const matched = [];
|
|
654
|
+
for (let i = 0; i < rows.length; i++) {
|
|
655
|
+
const row = rows[i];
|
|
656
|
+
const item = steering[i];
|
|
657
|
+
if (item !== void 0 && row.text === (item.text ?? "")) {
|
|
658
|
+
matched.push(item.id);
|
|
659
|
+
} else {
|
|
660
|
+
matched.push(null);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return matched;
|
|
664
|
+
}
|
|
665
|
+
function retractSpan(steering, targetId) {
|
|
666
|
+
const index = steering.findIndex((item) => item.id === targetId);
|
|
667
|
+
if (index === -1) return [];
|
|
668
|
+
return steering.slice(index).map((item) => item.id);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/client/portals.tsx
|
|
580
672
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
581
673
|
function userTextAt(session, seq) {
|
|
582
674
|
const snap = session.getSnapshot();
|
|
@@ -609,16 +701,36 @@ async function runRewindAndFill(session, seq, mode, currentSessionId) {
|
|
|
609
701
|
const result = await session.command(`/rewind @${seq} ${mode}`);
|
|
610
702
|
if (!result.ok || result.value?.matched !== true) return;
|
|
611
703
|
const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
612
|
-
if (outcome === null
|
|
704
|
+
if (outcome === null) return;
|
|
705
|
+
if (outcome.kind !== "success") {
|
|
706
|
+
showHint(outcome.text ?? "rewind failed");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
613
709
|
if (currentSessionId() !== session.sessionId) return;
|
|
614
710
|
const text = userTextAt(session, seq);
|
|
615
711
|
if (text === void 0 || text === "") return;
|
|
616
712
|
fillComposer(text);
|
|
617
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
|
+
}
|
|
618
729
|
var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
619
730
|
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"][data-chat-anchor-key], [data-chat-flow-kind="steering"][data-chat-anchor-key]';
|
|
620
731
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
621
732
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
733
|
+
var PENDING_SEAT_SELECTOR = "[data-pending-steering][data-time-hover-root]";
|
|
622
734
|
function collectTargets(chat, hiddenSeqs) {
|
|
623
735
|
const rows = /* @__PURE__ */ new Map();
|
|
624
736
|
for (const element of document.querySelectorAll(USER_SEAT_SELECTOR)) {
|
|
@@ -635,14 +747,51 @@ function collectTargets(chat, hiddenSeqs) {
|
|
|
635
747
|
const messageRoot = row?.querySelector(ACTIONS_ROOT_SELECTOR);
|
|
636
748
|
const actions = messageRoot?.lastElementChild;
|
|
637
749
|
if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
|
|
638
|
-
targets.push({ key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
|
|
750
|
+
targets.push({ kind: "durable", key, container: actions, seq: user.seq, time: user.time, preview: messagePreviewOf(user) });
|
|
751
|
+
}
|
|
752
|
+
return targets;
|
|
753
|
+
}
|
|
754
|
+
function bubbleTextOf(row) {
|
|
755
|
+
const clone = row.cloneNode(true);
|
|
756
|
+
clone.lastElementChild?.remove();
|
|
757
|
+
return clone.textContent ?? "";
|
|
758
|
+
}
|
|
759
|
+
function collectPendingTargets(snapshot) {
|
|
760
|
+
if (snapshot.subagent !== null) return [];
|
|
761
|
+
const steering = snapshot.queue.filter((item) => item.placement === "steering");
|
|
762
|
+
if (steering.length === 0) return [];
|
|
763
|
+
const rows = Array.from(document.querySelectorAll(PENDING_SEAT_SELECTOR));
|
|
764
|
+
const matched = matchPendingRows(
|
|
765
|
+
rows.map((row) => ({ text: bubbleTextOf(row) })),
|
|
766
|
+
steering.map((item) => ({ id: item.id, text: item.text }))
|
|
767
|
+
);
|
|
768
|
+
const targets = [];
|
|
769
|
+
for (let i = 0; i < matched.length; i++) {
|
|
770
|
+
const itemId = matched[i];
|
|
771
|
+
if (itemId === null) continue;
|
|
772
|
+
const row = rows[i];
|
|
773
|
+
if (row === void 0) continue;
|
|
774
|
+
const messageRoot = row.matches(ACTIONS_ROOT_SELECTOR) ? row : row.querySelector(ACTIONS_ROOT_SELECTOR);
|
|
775
|
+
const actions = messageRoot?.lastElementChild;
|
|
776
|
+
if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) continue;
|
|
777
|
+
const item = steering[i];
|
|
778
|
+
targets.push({
|
|
779
|
+
kind: "pending",
|
|
780
|
+
key: `pending:${itemId}`,
|
|
781
|
+
container: actions,
|
|
782
|
+
itemId,
|
|
783
|
+
text: item.text,
|
|
784
|
+
preview: item.preview
|
|
785
|
+
});
|
|
639
786
|
}
|
|
640
787
|
return targets;
|
|
641
788
|
}
|
|
642
789
|
function sameTargets(left, right) {
|
|
643
790
|
return left.length === right.length && left.every((target, index) => {
|
|
644
791
|
const other = right[index];
|
|
645
|
-
|
|
792
|
+
if (other === void 0 || target.key !== other.key || target.container !== other.container) return false;
|
|
793
|
+
if (target.kind === "durable") return other.kind === "durable" && target.seq === other.seq;
|
|
794
|
+
return other.kind === "pending" && target.itemId === other.itemId;
|
|
646
795
|
});
|
|
647
796
|
}
|
|
648
797
|
function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }) {
|
|
@@ -662,7 +811,8 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
662
811
|
setTargets([]);
|
|
663
812
|
return;
|
|
664
813
|
}
|
|
665
|
-
const
|
|
814
|
+
const snapshot = session.getSnapshot();
|
|
815
|
+
const chat = snapshot.chat;
|
|
666
816
|
const hiddenSeqs = hiddenSeqsOf(chat);
|
|
667
817
|
let hiddenCount = 0;
|
|
668
818
|
for (const seat of document.querySelectorAll(CHAT_SEAT_SELECTOR)) {
|
|
@@ -684,7 +834,7 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
684
834
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
685
835
|
);
|
|
686
836
|
}
|
|
687
|
-
const next = collectTargets(chat, hiddenSeqs);
|
|
837
|
+
const next = [...collectTargets(chat, hiddenSeqs), ...collectPendingTargets(snapshot)];
|
|
688
838
|
setTargets((current) => sameTargets(current, next) ? current : next);
|
|
689
839
|
};
|
|
690
840
|
const queueRefresh = () => {
|
|
@@ -704,7 +854,16 @@ function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLoc
|
|
|
704
854
|
};
|
|
705
855
|
}, [sessionId, sessionOf]);
|
|
706
856
|
return targets.map((target) => (0, import_react_dom.createPortal)(
|
|
707
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
857
|
+
target.kind === "pending" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
858
|
+
RetractButton,
|
|
859
|
+
{
|
|
860
|
+
target,
|
|
861
|
+
sessionId,
|
|
862
|
+
sessionOf,
|
|
863
|
+
t
|
|
864
|
+
},
|
|
865
|
+
target.key
|
|
866
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
708
867
|
RewindButton,
|
|
709
868
|
{
|
|
710
869
|
target,
|
|
@@ -753,6 +912,49 @@ function RewindButton({ target, sessionId, sessionOf, currentSessionId, t }) {
|
|
|
753
912
|
}
|
|
754
913
|
);
|
|
755
914
|
}
|
|
915
|
+
function composerText() {
|
|
916
|
+
const textarea = document.querySelector(COMPOSER_SELECTOR);
|
|
917
|
+
return textarea === null ? "" : textarea.value;
|
|
918
|
+
}
|
|
919
|
+
async function retractPending(session, itemId, text) {
|
|
920
|
+
await session.cancel();
|
|
921
|
+
const queue = session.getSnapshot().queue;
|
|
922
|
+
const steering = queue.filter((item) => item.placement === "steering");
|
|
923
|
+
for (const id of retractSpan(steering, itemId)) {
|
|
924
|
+
await session.updateQueue(id, { kind: "remove" });
|
|
925
|
+
}
|
|
926
|
+
if (text !== null && text !== "" && composerText().trim() === "") {
|
|
927
|
+
fillComposer(text);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function RetractButton({ target, sessionId, sessionOf, t }) {
|
|
931
|
+
const onClick = (event) => {
|
|
932
|
+
event.stopPropagation();
|
|
933
|
+
const session = sessionOf(sessionId);
|
|
934
|
+
if (session === void 0) return;
|
|
935
|
+
openPopover({
|
|
936
|
+
session,
|
|
937
|
+
preview: target.preview,
|
|
938
|
+
anchor: event.currentTarget,
|
|
939
|
+
t,
|
|
940
|
+
retract: { itemId: target.itemId, text: target.text },
|
|
941
|
+
onRetract: () => {
|
|
942
|
+
void retractPending(session, target.itemId, target.text);
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
947
|
+
"button",
|
|
948
|
+
{
|
|
949
|
+
type: "button",
|
|
950
|
+
className: CLASS.button,
|
|
951
|
+
"aria-label": t("button.retract.aria"),
|
|
952
|
+
title: t("button.retract.title"),
|
|
953
|
+
onClick,
|
|
954
|
+
dangerouslySetInnerHTML: { __html: REWIND_ICON_SVG }
|
|
955
|
+
}
|
|
956
|
+
);
|
|
957
|
+
}
|
|
756
958
|
function createRewindBridge(deps) {
|
|
757
959
|
return function RewindBridge({ sessionId }) {
|
|
758
960
|
return (0, import_react.createElement)(RewindPortals, { sessionId, ...deps });
|
|
@@ -763,8 +965,14 @@ function createRewindBridge(deps) {
|
|
|
763
965
|
var zh = {
|
|
764
966
|
"button.aria": "\u56DE\u9000\u5230\u6B64\u6D88\u606F",
|
|
765
967
|
"button.title": "\u56DE\u9000",
|
|
968
|
+
"button.retract.aria": "\u56DE\u9000\u5230\u6B64\u63D2\u8BDD\u6D88\u606F",
|
|
969
|
+
"button.retract.title": "\u56DE\u9000",
|
|
766
970
|
"popover.title": "\u56DE\u9000\u5230\u8FD9\u6761\u6D88\u606F",
|
|
767
971
|
"popover.noText": "\uFF08\u65E0\u6587\u672C\uFF09",
|
|
972
|
+
"popover.retract.title": "\u56DE\u9000\u5230\u8FD9\u6761\u63D2\u8BDD\u6D88\u606F",
|
|
973
|
+
"popover.retract.target": "\u63D2\u8BDD\u4E2D \xB7 {preview}",
|
|
974
|
+
"popover.retract.hint": "\u5C06\u505C\u6B62\u5F53\u524D\u751F\u6210\uFF0C\u5E76\u56DE\u9000\u5230\u8BE5\u6D88\u606F\u4E4B\u524D",
|
|
975
|
+
"popover.retract.confirm": "\u786E\u8BA4\u56DE\u9000",
|
|
768
976
|
"popover.chat": "\u4EC5\u56DE\u9000\u5BF9\u8BDD",
|
|
769
977
|
"popover.chat.hint": "\u53EA\u56DE\u9000\u6A21\u578B\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u52A8\u5DE5\u4F5C\u533A\u6587\u4EF6",
|
|
770
978
|
"popover.both": "\u56DE\u9000\u5BF9\u8BDD\u548C\u4EE3\u7801",
|
|
@@ -784,8 +992,14 @@ var zh = {
|
|
|
784
992
|
var en = {
|
|
785
993
|
"button.aria": "Rewind to this message",
|
|
786
994
|
"button.title": "Rewind",
|
|
995
|
+
"button.retract.aria": "Rewind to this pending message",
|
|
996
|
+
"button.retract.title": "Rewind",
|
|
787
997
|
"popover.title": "Rewind to this message",
|
|
788
998
|
"popover.noText": "(no text)",
|
|
999
|
+
"popover.retract.title": "Rewind to this pending message",
|
|
1000
|
+
"popover.retract.target": "Pending \xB7 {preview}",
|
|
1001
|
+
"popover.retract.hint": "Stops the current run and rewinds to before this message",
|
|
1002
|
+
"popover.retract.confirm": "Confirm rewind",
|
|
789
1003
|
"popover.chat": "Rewind conversation only",
|
|
790
1004
|
"popover.chat.hint": "Cut the model context only; workspace files stay untouched",
|
|
791
1005
|
"popover.both": "Rewind conversation and code",
|
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
|
}
|
|
@@ -653,6 +662,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
|
653
662
|
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
654
663
|
}
|
|
655
664
|
}
|
|
665
|
+
function dropPendingSteering(agent) {
|
|
666
|
+
for (const message of [...agent.inbox.nextStep]) {
|
|
667
|
+
agent.inbox.remove(message.id);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
656
670
|
async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
|
|
657
671
|
const { agent } = invocation;
|
|
658
672
|
const sessionId = agent.session.id;
|
|
@@ -662,12 +676,13 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
662
676
|
inflight.add(sessionId);
|
|
663
677
|
try {
|
|
664
678
|
if (agent.status !== "idle") {
|
|
665
|
-
agent.cancel({ kind: "user" });
|
|
679
|
+
agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
666
680
|
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
667
681
|
if (!stopped) {
|
|
668
682
|
return { kind: "error", text: t("stopFailed") };
|
|
669
683
|
}
|
|
670
684
|
}
|
|
685
|
+
dropPendingSteering(agent);
|
|
671
686
|
if (invocation.signal.aborted) {
|
|
672
687
|
return { kind: "error", text: t("cancelled") };
|
|
673
688
|
}
|
|
@@ -680,10 +695,19 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
|
|
|
680
695
|
const marker = buildMarker();
|
|
681
696
|
let event;
|
|
682
697
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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 });
|
|
687
711
|
} catch (error) {
|
|
688
712
|
return {
|
|
689
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';
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
export declare const zh: {
|
|
4
4
|
'button.aria': string;
|
|
5
5
|
'button.title': string;
|
|
6
|
+
'button.retract.aria': string;
|
|
7
|
+
'button.retract.title': string;
|
|
6
8
|
'popover.title': string;
|
|
7
9
|
'popover.noText': string;
|
|
10
|
+
'popover.retract.title': string;
|
|
11
|
+
'popover.retract.target': string;
|
|
12
|
+
'popover.retract.hint': string;
|
|
13
|
+
'popover.retract.confirm': string;
|
|
8
14
|
'popover.chat': string;
|
|
9
15
|
'popover.chat.hint': string;
|
|
10
16
|
'popover.both': string;
|
|
@@ -33,8 +39,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
33
39
|
export declare const en: {
|
|
34
40
|
'button.aria': string;
|
|
35
41
|
'button.title': string;
|
|
42
|
+
'button.retract.aria': string;
|
|
43
|
+
'button.retract.title': string;
|
|
36
44
|
'popover.title': string;
|
|
37
45
|
'popover.noText': string;
|
|
46
|
+
'popover.retract.title': string;
|
|
47
|
+
'popover.retract.target': string;
|
|
48
|
+
'popover.retract.hint': string;
|
|
49
|
+
'popover.retract.confirm': string;
|
|
38
50
|
'popover.chat': string;
|
|
39
51
|
'popover.chat.hint': string;
|
|
40
52
|
'popover.both': string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pending-message matching: pairs the rendered pending-steering bubble
|
|
3
|
+
* rows with the session's transient queue mirror rows (`placement === 'steering'`).
|
|
4
|
+
*
|
|
5
|
+
* Both sides derive from the host's next-step inbox order — the ChatView
|
|
6
|
+
* renders `pendingSteering` in array order and the queue mirror keeps the same
|
|
7
|
+
* host order — so index-primary matching is reliable. Text equality is still
|
|
8
|
+
* verified as a per-row cross-check, and a row that fails (or a row with no
|
|
9
|
+
* mirror item, or a mirror item with no row) is skipped INDIVIDUALLY: one bad
|
|
10
|
+
* row never takes down the other rows' buttons. The matching text is the
|
|
11
|
+
* bubble's message text WITHOUT its actions container — the harness copy
|
|
12
|
+
* button's Tooltip mounts a label bubble inside that container on hover, so
|
|
13
|
+
* the full row textContent would flip between "message" and "message+Copy"
|
|
14
|
+
* with the mouse, flickering the button (see `bubbleTextOf` in portals.tsx).
|
|
15
|
+
*
|
|
16
|
+
* The browser half lives in `portals.tsx`; this module stays DOM-free so the
|
|
17
|
+
* matching contract is unit-testable in a plain node environment.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-rewind/client/pending
|
|
20
|
+
*/
|
|
21
|
+
/** One rendered pending-steering bubble row (only the fields matching reads). */
|
|
22
|
+
export interface PendingRow {
|
|
23
|
+
/** The bubble's message text, excluding the actions container (see module doc). */
|
|
24
|
+
readonly text: string;
|
|
25
|
+
}
|
|
26
|
+
/** One steering occurrence from the session queue mirror. */
|
|
27
|
+
export interface PendingSteeringItem {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/** Complete editable text; null when the message contains non-text blocks. */
|
|
30
|
+
readonly text: string | null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Pair rows to steering items by index, verifying text equality per row.
|
|
34
|
+
* @param rows - pending bubble rows in DOM order (== render order).
|
|
35
|
+
* @param steering - steering queue items in host order (== render order).
|
|
36
|
+
* @returns the item id for each row, or null for rows that cannot be matched
|
|
37
|
+
* safely (missing counterpart, text mismatch). A bad row never affects the
|
|
38
|
+
* other rows.
|
|
39
|
+
*/
|
|
40
|
+
export declare function matchPendingRows(rows: readonly PendingRow[], steering: readonly PendingSteeringItem[]): readonly (string | null)[];
|
|
41
|
+
/**
|
|
42
|
+
* The pending-steering ids a "rewind to this pre-sent message" retracts: the
|
|
43
|
+
* target occurrence and every steering message after it, in inbox (FIFO)
|
|
44
|
+
* order. Queued (next-turn) messages are deliberately NOT included — the
|
|
45
|
+
* harness QueueDock already offers the user per-item edit/remove, so a rewind
|
|
46
|
+
* must not silently drop messages the user may still want to send.
|
|
47
|
+
* @param steering - steering queue items in host order (== render order).
|
|
48
|
+
* @param targetId - the rewind target's inbox occurrence id.
|
|
49
|
+
* @returns the ids to remove, oldest-first; empty when the target is no
|
|
50
|
+
* longer pending (already claimed/consumed).
|
|
51
|
+
*/
|
|
52
|
+
export declare function retractSpan(steering: readonly {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
}[], targetId: string): readonly string[];
|
|
@@ -19,8 +19,17 @@ import type { RewindKey } from './locales.ts';
|
|
|
19
19
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
20
20
|
export interface PopoverOptions {
|
|
21
21
|
readonly session: SessionFace;
|
|
22
|
-
|
|
23
|
-
readonly
|
|
22
|
+
/** Durable variant: the target message seq (mode-selection flow). */
|
|
23
|
+
readonly seq?: number;
|
|
24
|
+
/** Durable variant: the target message time. */
|
|
25
|
+
readonly time?: number;
|
|
26
|
+
/** Pending variant: retract a pre-sent steering message (single-confirm flow). */
|
|
27
|
+
readonly retract?: {
|
|
28
|
+
readonly itemId: string;
|
|
29
|
+
readonly text: string | null;
|
|
30
|
+
};
|
|
31
|
+
/** Pending variant: executed after the retract confirm closes the popover. */
|
|
32
|
+
readonly onRetract?: () => void;
|
|
24
33
|
readonly preview: string;
|
|
25
34
|
/** The button that opened the popover (outside-click ignore target). */
|
|
26
35
|
readonly anchor: HTMLElement;
|
|
@@ -30,7 +39,7 @@ export interface PopoverOptions {
|
|
|
30
39
|
* the callback owns the command + composer-refill lifecycle (see
|
|
31
40
|
* runRewindAndFill in index.ts).
|
|
32
41
|
*/
|
|
33
|
-
readonly onRewind
|
|
42
|
+
readonly onRewind?: (mode: 'chat' | 'both') => void;
|
|
34
43
|
}
|
|
35
44
|
/** Close the current popover, if any. */
|
|
36
45
|
export declare function closePopover(): void;
|
|
@@ -26,6 +26,28 @@ import { type ReactNode } from 'react';
|
|
|
26
26
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
27
27
|
import type { RewindKey } from './locales.ts';
|
|
28
28
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
29
|
+
/** One portal target: the actions row of a user/steering seat + its durable node. */
|
|
30
|
+
export type PortalTarget = {
|
|
31
|
+
readonly kind: 'durable';
|
|
32
|
+
/** The seat's chat node key (React reconciliation + diff identity). */
|
|
33
|
+
readonly key: string;
|
|
34
|
+
/** The row's actions container (React portal target). */
|
|
35
|
+
readonly container: HTMLElement;
|
|
36
|
+
readonly seq: number;
|
|
37
|
+
readonly time: number;
|
|
38
|
+
readonly preview: string;
|
|
39
|
+
} | {
|
|
40
|
+
readonly kind: 'pending';
|
|
41
|
+
/** `pending:${itemId}` — stable per inbox occurrence. */
|
|
42
|
+
readonly key: string;
|
|
43
|
+
/** The row's actions container (React portal target). */
|
|
44
|
+
readonly container: HTMLElement;
|
|
45
|
+
/** The host inbox occurrence the retract button addresses. */
|
|
46
|
+
readonly itemId: string;
|
|
47
|
+
/** Complete editable text; null when the message contains non-text blocks. */
|
|
48
|
+
readonly text: string | null;
|
|
49
|
+
readonly preview: string;
|
|
50
|
+
};
|
|
29
51
|
/** Capabilities the session-scoped bridge receives from the plugin apply(). */
|
|
30
52
|
export interface RewindBridgeDeps {
|
|
31
53
|
readonly sessionOf: (sessionId: string) => SessionFace | undefined;
|
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.0",
|
|
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"
|
|
@@ -132,6 +128,8 @@
|
|
|
132
128
|
"@deepseek-ai/dsh-client-ui-input-trigger": "^0.1.0-rc.7",
|
|
133
129
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
134
130
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
|
|
131
|
+
"@deepseek-ai/dsh-compaction": "^0.1.0-rc.7",
|
|
132
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.0-rc.7",
|
|
135
133
|
"@deepseek-ai/dsh-fs": "^0.1.0-rc.7",
|
|
136
134
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
137
135
|
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.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.'}`)
|