dsh-rewind-plugin 0.2.5 → 0.2.7
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 +16 -7
- package/README.zh.md +14 -7
- package/lib/client.js +51 -43
- package/lib/index.js +78 -49
- package/lib/types/client/hidden.d.ts +20 -1
- package/lib/types/client/popover.d.ts +24 -0
- package/package.json +6 -2
- package/scripts/repair-markers.mjs +176 -0
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
In-place conversation rewind for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the Claude Code `/rewind` semantics inside the **same session window** — cut the model context back to an earlier user message, and optionally restore workspace files from **disk-persisted before-backups**.
|
|
6
6
|
|
|
7
|
-
> **Status:** published to npm (`dsh-rewind-plugin`, v0.2.
|
|
7
|
+
> **Status:** published to npm (`dsh-rewind-plugin`, v0.2.7) via GitHub Actions Trusted Publishing + Sigstore provenance. Targets the web profile (`dsh --profile web`). Interaction mirrors Claude Code's rewind, adapted to dsh's real web UI.
|
|
8
|
+
>
|
|
9
|
+
> **v0.2.7 highlights:** composer refill is event-driven (reopening a session never resurrects withdrawn text); subagent edits are not tracked (Claude Code alignment); concurrent rewinds are guarded; the code-restore option appears only when tracked changes exist (mixed host/client versions safe).
|
|
8
10
|
|
|
9
11
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
12
|
[](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
|
|
@@ -85,20 +87,26 @@ conversation**: the marker's turn number collided with the next real turn's
|
|
|
85
87
|
`Failed to load history: conversation Context …:turn-tail… received an update before its start Match (internal)`
|
|
86
88
|
and the history vanished. Rewinds created from 0.2.5 on no longer produce the
|
|
87
89
|
collision, but **already-corrupted sessions need an offline repair** (the log is
|
|
88
|
-
append-only — it cannot be rewritten in memory)
|
|
90
|
+
append-only — it cannot be rewritten in memory).
|
|
91
|
+
|
|
92
|
+
The repair tool ships **inside the npm package** (`dsh-rewind-repair`) — no
|
|
93
|
+
source checkout needed:
|
|
89
94
|
|
|
90
95
|
```sh
|
|
91
96
|
# 1. Fully quit dsh web / host first (while a session is resident in memory,
|
|
92
97
|
# a disk repair is overwritten by the next checkpoint)
|
|
93
|
-
# 2. Run the offline repair
|
|
98
|
+
# 2. Run the offline repair (scans every session under ~/.dsh/sessions,
|
|
94
99
|
# rewriting each marker's turn back to the last started turn)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
node scripts/repair-markers.mjs --dir <sessions root> # custom data dir
|
|
100
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
101
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # preview only
|
|
98
102
|
# 3. Restart dsh web — the repaired sessions load their history again
|
|
99
103
|
```
|
|
100
104
|
|
|
101
|
-
|
|
105
|
+
Or install it globally once (`npm i -g dsh-rewind-plugin`) and run
|
|
106
|
+
`dsh-rewind-repair` directly; from a source checkout the same tool is
|
|
107
|
+
`node scripts/repair-markers.mjs` (identical flags).
|
|
108
|
+
|
|
109
|
+
The tool only rewrites the `data.turn` of `dsh-rewind` empty-marker events
|
|
102
110
|
(keeping seqs, order, and the zstd frame structure intact), backs up the original
|
|
103
111
|
file to `session.jsonl.zstd.bak-<timestamp>` before writing, and never touches
|
|
104
112
|
any other event — safe to run repeatedly.
|
|
@@ -157,6 +165,7 @@ Rewinding to a message **withdraws** it and everything after it — the transcri
|
|
|
157
165
|
## Behavior details & limitations
|
|
158
166
|
|
|
159
167
|
- Only **write-class tools** running while the plugin is active are tracked (`write` / `edit` / `str_replace_editor`). Changes made by `bash`, other tools, or external programs are not backed up and cannot be restored — the same limitation as Claude Code, which also defers such rollbacks to the user's git.
|
|
168
|
+
- **Subagent edits are not tracked** — same as Claude Code. A subagent runs its own session, so its backups could never be restored by a rewind of the parent session; the capture is skipped instead of recording to an unreachable store.
|
|
160
169
|
- If a before-capture read fails (e.g. a permission error), that change is simply not backed up and a `both` rewind cannot restore it — the plugin logs a warning but **does not block the write**.
|
|
161
170
|
- File restore/delete writes through the **real local filesystem**; under sandbox / remote backends path resolution may be restricted.
|
|
162
171
|
- Symbolic links and hard links are not written through (they share the inode with another name; a restore would clobber both) — they are skipped and reported.
|
package/README.zh.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)——把模型上下文剪回更早的一条用户消息,并可基于**落盘的写前备份**还原工作区文件。
|
|
6
6
|
|
|
7
|
-
> **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.
|
|
7
|
+
> **状态**:已发布 npm(`dsh-rewind-plugin`,v0.2.7),经 GitHub Actions Trusted Publishing + Sigstore provenance 构建发布。目标为 web 配置档(`dsh --profile web`)。交互以 Claude Code 的 rewind 为参考,并贴合 dsh Web 实际 UI。
|
|
8
|
+
>
|
|
9
|
+
> **v0.2.7 要点**:输入框回填改为事件驱动(重开会话不再复活已撤回文本);子代理编辑不跟踪(对齐 Claude Code);并发回退有防护;「回退代码」选项仅在存在跟踪变更时显示(兼容新旧 host/client 混合版本)。
|
|
8
10
|
|
|
9
11
|
[](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
10
12
|
[](https://github.com/SiriLee/dsh-rewind/blob/main/LICENSE)
|
|
@@ -83,18 +85,22 @@
|
|
|
83
85
|
与下一条真实回合的 `turn/start` 编号冲突,重新打开会话时界面报
|
|
84
86
|
`历史加载失败:conversation Context …:turn-tail… received an update before its start Match(internal)`,
|
|
85
87
|
历史整段消失。0.2.5 起新的回退不再产生该冲突;但**已损坏的会话需要离线修复**(日志是
|
|
86
|
-
append-only
|
|
88
|
+
append-only 的,不能在内存中改写)。
|
|
89
|
+
|
|
90
|
+
修复工具**已随 npm 包发布**(`dsh-rewind-repair`)——无需下载源码:
|
|
87
91
|
|
|
88
92
|
```sh
|
|
89
93
|
# 1. 先完全退出 dsh web / host(会话处于驻留内存时,磁盘修复会被下次 checkpoint 覆盖)
|
|
90
|
-
# 2.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
node scripts/repair-markers.mjs --dir <sessions 根目录> # 指定数据目录
|
|
94
|
+
# 2. 运行离线修复(扫描 ~/.dsh/sessions 下所有会话,把标记 turn 改回最后一个已开始的回合)
|
|
95
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair
|
|
96
|
+
npm exec --yes --package=dsh-rewind-plugin -- dsh-rewind-repair -- --dry-run # 只预览不写盘
|
|
94
97
|
# 3. 重启 dsh web,损坏的会话即可正常加载历史
|
|
95
98
|
```
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
也可以全局安装一次(`npm i -g dsh-rewind-plugin`)后直接运行 `dsh-rewind-repair`;
|
|
101
|
+
源码方式为 `node scripts/repair-markers.mjs`(参数相同)。
|
|
102
|
+
|
|
103
|
+
工具只改写 `dsh-rewind` 空标记事件的 `data.turn` 字段(保持 seq / 顺序 / zstd 帧结构不变),
|
|
98
104
|
改前自动备份原文件为 `session.jsonl.zstd.bak-<时间戳>`;不改动任何其它事件,可安全重复运行。
|
|
99
105
|
|
|
100
106
|
## 📦 安装
|
|
@@ -150,6 +156,7 @@ dsh plugin --profile web add github:SiriLee/dsh-rewind#<commit-sha>
|
|
|
150
156
|
## 行为细节与限制
|
|
151
157
|
|
|
152
158
|
- 只跟踪**插件运行期间、经写类工具**的变更(`write` / `edit` / `str_replace_editor`)。`bash`、其他工具或外部程序的修改不在备份内、无法还原——与 Claude Code 相同,官方同样不覆盖,此类回退交由用户 git 处理。
|
|
159
|
+
- **子代理(subagent)的编辑不跟踪**——与 Claude Code 相同。子代理运行在自己的会话里,其备份无法被父会话的回退还原;插件直接跳过捕获,而不是记录到永远读不到的位置。
|
|
153
160
|
- 写前备份读取失败时(如权限错误)该次变更不会入备份,`both` 回退无法还原它——插件会在日志中警告,但**不会阻塞写操作**。
|
|
154
161
|
- 文件还原/删除直写**真实本地文件系统**;sandbox / 远程 backend 下路径解析可能受限。
|
|
155
162
|
- 符号链接与硬链接不写入(它们与另一名字共享 inode,还原会互相污染)——跳过并在结果中提示。
|
package/lib/client.js
CHANGED
|
@@ -38,6 +38,18 @@ function targetOfOutcome(text) {
|
|
|
38
38
|
const match = text.match(/seq (\d+)/);
|
|
39
39
|
return match !== null ? Number(match[1]) : void 0;
|
|
40
40
|
}
|
|
41
|
+
function isExecutedRewindCommand(node, seq) {
|
|
42
|
+
if (node.name !== "rewind" || node.outcome?.kind !== "success") return false;
|
|
43
|
+
if (node.outcome.sourceEventSeq === void 0) return false;
|
|
44
|
+
const args = node.args ?? "";
|
|
45
|
+
return new RegExp(`(?:^|\\s)@${seq}(?:\\s|$)`).test(args);
|
|
46
|
+
}
|
|
47
|
+
function hasFileImpact(text) {
|
|
48
|
+
if (text === void 0) return true;
|
|
49
|
+
const match = text.match(/impact=(\d+)/);
|
|
50
|
+
if (match !== null) return Number(match[1]) > 0;
|
|
51
|
+
return text.includes("\u5C06\u5F71\u54CD");
|
|
52
|
+
}
|
|
41
53
|
function isPreviewCommand(command) {
|
|
42
54
|
return (command.args ?? "").includes("preview");
|
|
43
55
|
}
|
|
@@ -291,6 +303,9 @@ function formatTarget(t, seq, time, preview) {
|
|
|
291
303
|
const previewText = preview.length > 0 ? preview : t("popover.noText");
|
|
292
304
|
return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
|
|
293
305
|
}
|
|
306
|
+
function stripImpactToken(text) {
|
|
307
|
+
return text.replace(/\n?impact=\d+\s*$/, "");
|
|
308
|
+
}
|
|
294
309
|
function findCommand(snapshot, match) {
|
|
295
310
|
let found;
|
|
296
311
|
for (const key of snapshot.chat.order) {
|
|
@@ -302,6 +317,18 @@ function findCommand(snapshot, match) {
|
|
|
302
317
|
}
|
|
303
318
|
return found;
|
|
304
319
|
}
|
|
320
|
+
function knownCommandSeqs(session, match) {
|
|
321
|
+
const known = /* @__PURE__ */ new Set();
|
|
322
|
+
const snapshot = session.getSnapshot();
|
|
323
|
+
for (const key of snapshot.chat.order) {
|
|
324
|
+
const node = snapshot.chat.nodes.get(key);
|
|
325
|
+
if (node !== void 0 && node.kind === "command") {
|
|
326
|
+
const command = node.data;
|
|
327
|
+
if (match(command)) known.add(command.seq);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return known;
|
|
331
|
+
}
|
|
305
332
|
function waitForCommand(session, match, timeoutMs = 8e3) {
|
|
306
333
|
return new Promise((resolve) => {
|
|
307
334
|
let settled = false;
|
|
@@ -328,9 +355,10 @@ function isPreviewFor(node, seq) {
|
|
|
328
355
|
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
329
356
|
}
|
|
330
357
|
async function previewImpact(session, seq) {
|
|
358
|
+
const known = knownCommandSeqs(session, (node) => isPreviewFor(node, seq));
|
|
331
359
|
const result = await session.command(`/rewind preview @${seq} both`);
|
|
332
360
|
if (!result.ok || result.value?.matched !== true) return null;
|
|
333
|
-
return waitForCommand(session, (node) => isPreviewFor(node, seq));
|
|
361
|
+
return waitForCommand(session, (node) => isPreviewFor(node, seq) && !known.has(node.seq));
|
|
334
362
|
}
|
|
335
363
|
function el(tag, className, text) {
|
|
336
364
|
const node = document.createElement(tag);
|
|
@@ -375,11 +403,11 @@ function renderImpactStep(root, opts, back, cached) {
|
|
|
375
403
|
impact.textContent = t("popover.impact.failed", { message: outcome.text ?? "unknown error" });
|
|
376
404
|
return;
|
|
377
405
|
}
|
|
378
|
-
impact.textContent = outcome.text
|
|
406
|
+
impact.textContent = outcome.text === void 0 ? t("popover.impact.none") : stripImpactToken(outcome.text);
|
|
379
407
|
confirm.disabled = false;
|
|
380
408
|
confirm.addEventListener("click", () => {
|
|
381
409
|
closePopover();
|
|
382
|
-
|
|
410
|
+
opts.onRewind("both");
|
|
383
411
|
});
|
|
384
412
|
})().catch(() => {
|
|
385
413
|
impact.textContent = t("popover.impact.failed", { message: "unexpected error" });
|
|
@@ -399,7 +427,7 @@ function openPopover(opts) {
|
|
|
399
427
|
el("div", CLASS.popoverTarget, formatTarget(t, seq, time, preview)),
|
|
400
428
|
modeOption(t("popover.chat"), t("popover.chat.hint"), () => {
|
|
401
429
|
closePopover();
|
|
402
|
-
|
|
430
|
+
opts.onRewind("chat");
|
|
403
431
|
})
|
|
404
432
|
];
|
|
405
433
|
if (bothState.state === "noChanges") {
|
|
@@ -436,7 +464,6 @@ function openPopover(opts) {
|
|
|
436
464
|
renderModes();
|
|
437
465
|
document.body.append(root);
|
|
438
466
|
position();
|
|
439
|
-
const hasFileImpact = (text) => text === void 0 || text.includes("\u5C06\u5F71\u54CD");
|
|
440
467
|
void (async () => {
|
|
441
468
|
const outcome = await previewImpact(session, seq);
|
|
442
469
|
impactOutcome = outcome;
|
|
@@ -474,7 +501,7 @@ function openPopover(opts) {
|
|
|
474
501
|
var name = "dsh-rewind";
|
|
475
502
|
var inject = ["sessions", "locale"];
|
|
476
503
|
var NS = "rewind";
|
|
477
|
-
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"]';
|
|
504
|
+
var USER_SEAT_SELECTOR = '[data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]';
|
|
478
505
|
var CHAT_SEAT_SELECTOR = "[data-chat-anchor-key]";
|
|
479
506
|
var ACTIONS_ROOT_SELECTOR = "[data-time-hover-root]";
|
|
480
507
|
var COMPOSER_SELECTOR = "[data-input-scroll] textarea, textarea[data-phase]";
|
|
@@ -511,7 +538,6 @@ function apply(ctx) {
|
|
|
511
538
|
style.dataset.plugin = "dsh-rewind";
|
|
512
539
|
style.textContent = STYLE;
|
|
513
540
|
document.head.appendChild(style);
|
|
514
|
-
const attached = /* @__PURE__ */ new WeakSet();
|
|
515
541
|
const hidden = /* @__PURE__ */ new WeakSet();
|
|
516
542
|
const buttons = /* @__PURE__ */ new Map();
|
|
517
543
|
let observer = null;
|
|
@@ -529,16 +555,17 @@ function apply(ctx) {
|
|
|
529
555
|
};
|
|
530
556
|
const userNodeFor = (key, session) => {
|
|
531
557
|
const node = session.getSnapshot().chat.nodes.get(key);
|
|
532
|
-
if (node === void 0 || node.kind !== "user") return void 0;
|
|
558
|
+
if (node === void 0 || node.kind !== "user" && node.kind !== "steering") return void 0;
|
|
533
559
|
return node.data;
|
|
534
560
|
};
|
|
535
561
|
const attach = (seat) => {
|
|
536
562
|
const key = seat.dataset.chatAnchorKey;
|
|
537
|
-
if (key === void 0
|
|
563
|
+
if (key === void 0) return;
|
|
564
|
+
const existing = buttons.get(key);
|
|
565
|
+
if (existing !== void 0 && existing.isConnected) return;
|
|
538
566
|
const hoverRoot = seat.querySelector(ACTIONS_ROOT_SELECTOR);
|
|
539
567
|
const actions = hoverRoot?.lastElementChild;
|
|
540
568
|
if (!(actions instanceof HTMLElement) || actions.querySelector("button") === null) return;
|
|
541
|
-
attached.add(seat);
|
|
542
569
|
const button = document.createElement("button");
|
|
543
570
|
button.type = "button";
|
|
544
571
|
button.className = CLASS.button;
|
|
@@ -560,7 +587,10 @@ function apply(ctx) {
|
|
|
560
587
|
time: node.time,
|
|
561
588
|
preview: messagePreviewOf(node),
|
|
562
589
|
anchor: button,
|
|
563
|
-
t
|
|
590
|
+
t,
|
|
591
|
+
onRewind: (mode) => {
|
|
592
|
+
void runRewindAndFill(session, node.seq, mode);
|
|
593
|
+
}
|
|
564
594
|
});
|
|
565
595
|
});
|
|
566
596
|
actions.appendChild(button);
|
|
@@ -586,20 +616,6 @@ function apply(ctx) {
|
|
|
586
616
|
hidden.delete(seat);
|
|
587
617
|
}
|
|
588
618
|
}
|
|
589
|
-
if (session !== void 0) {
|
|
590
|
-
const snap = session.getSnapshot();
|
|
591
|
-
if (!fillBaselineTaken && snap.chat.order.length > 0) {
|
|
592
|
-
fillBaselineTaken = true;
|
|
593
|
-
for (const key of snap.chat.order) {
|
|
594
|
-
const node = snap.chat.nodes.get(key);
|
|
595
|
-
if (node?.kind === "command") {
|
|
596
|
-
const command = node.data;
|
|
597
|
-
if (command.seq > fillBaselineSeq) fillBaselineSeq = command.seq;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
fillComposerForRewind(session, filledTargets);
|
|
602
|
-
}
|
|
603
619
|
if (hiddenSeqs.size > 0 || hiddenCount > 0) {
|
|
604
620
|
console.info(
|
|
605
621
|
`[dsh-rewind] hiding: ${hiddenCount} rows, seqs [${[...hiddenSeqs].slice(0, 20).join(", ")}${hiddenSeqs.size > 20 ? "\u2026" : ""}]`
|
|
@@ -609,24 +625,16 @@ function apply(ctx) {
|
|
|
609
625
|
if (!hidden.has(seat)) attach(seat);
|
|
610
626
|
}
|
|
611
627
|
};
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (command.seq <= fillBaselineSeq) continue;
|
|
623
|
-
if (command.outcome.sourceEventSeq === void 0) continue;
|
|
624
|
-
const target = targetOfOutcome(command.outcome.text);
|
|
625
|
-
if (target === void 0 || filled.has(target)) continue;
|
|
626
|
-
const text = userTextAt(session, target);
|
|
627
|
-
if (text === void 0 || text === "") continue;
|
|
628
|
-
if (fillComposer(text)) filled.add(target);
|
|
629
|
-
}
|
|
628
|
+
const runRewindAndFill = async (session, seq, mode) => {
|
|
629
|
+
const known = knownCommandSeqs(session, (node) => isExecutedRewindCommand(node, seq));
|
|
630
|
+
const result = await session.command(`/rewind @${seq} ${mode}`);
|
|
631
|
+
if (!result.ok || result.value?.matched !== true) return;
|
|
632
|
+
const outcome = await waitForCommand(session, (node) => isExecutedRewindCommand(node, seq) && !known.has(node.seq), 2e4);
|
|
633
|
+
if (outcome === null || outcome.kind !== "success") return;
|
|
634
|
+
if (sessionFor() !== session) return;
|
|
635
|
+
const text = userTextAt(session, seq);
|
|
636
|
+
if (text === void 0 || text === "") return;
|
|
637
|
+
fillComposer(text);
|
|
630
638
|
};
|
|
631
639
|
const MANUAL_REWIND = /^\s*\/rewind(?:\s|$)/i;
|
|
632
640
|
const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR);
|
package/lib/index.js
CHANGED
|
@@ -309,7 +309,7 @@ function mutationPathOf(exec) {
|
|
|
309
309
|
}
|
|
310
310
|
function anchorSeqOf(session, cache) {
|
|
311
311
|
const events = session.events;
|
|
312
|
-
const cached = cache.get(session
|
|
312
|
+
const cached = cache.get(session);
|
|
313
313
|
if (cached !== void 0 && cached.eventsLength === events.length) return cached.anchor;
|
|
314
314
|
let anchor = cached?.anchor;
|
|
315
315
|
for (let i = events.length - 1; i >= (cached?.eventsLength ?? 0); i--) {
|
|
@@ -318,7 +318,7 @@ function anchorSeqOf(session, cache) {
|
|
|
318
318
|
break;
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
|
-
cache.set(session
|
|
321
|
+
cache.set(session, { anchor, eventsLength: events.length });
|
|
322
322
|
return anchor;
|
|
323
323
|
}
|
|
324
324
|
async function resolveTarget(fs, path, cwd, signal) {
|
|
@@ -342,6 +342,8 @@ async function readTextOrUndefined(fs, target, signal) {
|
|
|
342
342
|
}
|
|
343
343
|
async function captureBefore(fs, exec, pending) {
|
|
344
344
|
if (!TRACKED_TOOLS.has(exec.name)) return;
|
|
345
|
+
const header = exec.agent?.session.header;
|
|
346
|
+
if (header !== void 0 && (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0)) return;
|
|
345
347
|
const path = mutationPathOf(exec);
|
|
346
348
|
if (path === void 0) return;
|
|
347
349
|
const cwd = execSessionCwd(exec, path);
|
|
@@ -388,6 +390,7 @@ function formatPlan(plan, files) {
|
|
|
388
390
|
} else {
|
|
389
391
|
lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u5FEB\u7167\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
|
|
390
392
|
}
|
|
393
|
+
lines.push(`impact=${files.length}`);
|
|
391
394
|
return lines.join("\n");
|
|
392
395
|
}
|
|
393
396
|
function resolveOrError(events, surface, raw) {
|
|
@@ -402,56 +405,81 @@ function renderFailures(failed) {
|
|
|
402
405
|
return `\uFF1B${failed.length} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A${failed.map((f) => `${f.path}\uFF08${f.message}\uFF09`).join("\u3001")}`;
|
|
403
406
|
}
|
|
404
407
|
async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
408
|
+
if (signal.aborted) return false;
|
|
409
|
+
let timer;
|
|
410
|
+
let onAbort;
|
|
411
|
+
try {
|
|
412
|
+
await Promise.race([
|
|
413
|
+
agent.whenIdle(),
|
|
414
|
+
new Promise((_resolve, reject) => {
|
|
415
|
+
timer = setTimeout(() => reject(new Error("rewind idle wait timed out")), timeoutMs);
|
|
416
|
+
onAbort = () => reject(new Error("rewind idle wait aborted"));
|
|
417
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
418
|
+
})
|
|
419
|
+
]);
|
|
420
|
+
return true;
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
} finally {
|
|
424
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
425
|
+
if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
|
|
409
426
|
}
|
|
410
|
-
return true;
|
|
411
427
|
}
|
|
412
|
-
async function executeRewind(ctx, store, invocation, rawTarget, mode) {
|
|
428
|
+
async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
|
|
413
429
|
const { agent } = invocation;
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if (!stopped) {
|
|
418
|
-
return { kind: "error", text: "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
let plan;
|
|
422
|
-
try {
|
|
423
|
-
plan = resolveOrError(agent.session.events, agent.session.surface.nodes, rawTarget);
|
|
424
|
-
} catch (error) {
|
|
425
|
-
return rewindErrorResult(error);
|
|
430
|
+
const sessionId = agent.session.id;
|
|
431
|
+
if (inflight.has(sessionId)) {
|
|
432
|
+
return { kind: "error", text: "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002" };
|
|
426
433
|
}
|
|
427
|
-
|
|
428
|
-
let event;
|
|
434
|
+
inflight.add(sessionId);
|
|
429
435
|
try {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
436
|
+
if (agent.status !== "idle") {
|
|
437
|
+
agent.cancel({ kind: "user" });
|
|
438
|
+
const stopped = await waitForAgentIdle(agent, invocation.signal);
|
|
439
|
+
if (!stopped) {
|
|
440
|
+
return { kind: "error", text: "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (invocation.signal.aborted) {
|
|
444
|
+
return { kind: "error", text: "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002" };
|
|
445
|
+
}
|
|
446
|
+
let plan;
|
|
447
|
+
try {
|
|
448
|
+
plan = resolveOrError(agent.session.events, agent.session.surface.nodes, rawTarget);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
return rewindErrorResult(error);
|
|
451
|
+
}
|
|
452
|
+
const marker = buildMarker();
|
|
453
|
+
let event;
|
|
454
|
+
try {
|
|
455
|
+
event = agent.session.append("assistant/message", { turn: markerTurnOf(agent.session.events), step: 0, message: marker }, {
|
|
456
|
+
surfaceOp: { op: "replace", start: plan.surfaceStart, end: plan.surfaceEnd },
|
|
457
|
+
sourceEventSeqs: [...plan.shadowedSeqs]
|
|
458
|
+
});
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return {
|
|
461
|
+
kind: "error",
|
|
462
|
+
text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
let restore = "";
|
|
466
|
+
if (mode === "both") {
|
|
467
|
+
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
468
|
+
const parts = [];
|
|
469
|
+
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
470
|
+
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
471
|
+
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
|
|
472
|
+
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
473
|
+
restore += renderFailures(outcome.failed);
|
|
474
|
+
}
|
|
435
475
|
return {
|
|
436
|
-
kind: "
|
|
437
|
-
text: `\
|
|
476
|
+
kind: "success",
|
|
477
|
+
text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
|
|
478
|
+
sourceEventSeq: event.seq
|
|
438
479
|
};
|
|
480
|
+
} finally {
|
|
481
|
+
inflight.delete(sessionId);
|
|
439
482
|
}
|
|
440
|
-
let restore = "";
|
|
441
|
-
if (mode === "both") {
|
|
442
|
-
const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
|
|
443
|
-
const parts = [];
|
|
444
|
-
if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
|
|
445
|
-
if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
|
|
446
|
-
if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
|
|
447
|
-
restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
|
|
448
|
-
restore += renderFailures(outcome.failed);
|
|
449
|
-
}
|
|
450
|
-
return {
|
|
451
|
-
kind: "success",
|
|
452
|
-
text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
|
|
453
|
-
sourceEventSeq: event.seq
|
|
454
|
-
};
|
|
455
483
|
}
|
|
456
484
|
function rewindErrorResult(error) {
|
|
457
485
|
if (error instanceof RewindError) {
|
|
@@ -465,7 +493,7 @@ function rewindErrorResult(error) {
|
|
|
465
493
|
}
|
|
466
494
|
throw error;
|
|
467
495
|
}
|
|
468
|
-
async function handleRewind(ctx, store, invocation) {
|
|
496
|
+
async function handleRewind(ctx, store, invocation, inflight) {
|
|
469
497
|
const session = invocation.agent.session;
|
|
470
498
|
const input = invocation.rawInput.trim();
|
|
471
499
|
if (input === "") {
|
|
@@ -473,7 +501,7 @@ async function handleRewind(ctx, store, invocation) {
|
|
|
473
501
|
if (candidates.length === 0) {
|
|
474
502
|
return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
|
|
475
503
|
}
|
|
476
|
-
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat");
|
|
504
|
+
return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
|
|
477
505
|
}
|
|
478
506
|
const parts = input.split(/\s+/);
|
|
479
507
|
if (parts[0] === "preview") {
|
|
@@ -503,17 +531,18 @@ async function handleRewind(ctx, store, invocation) {
|
|
|
503
531
|
/rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
|
|
504
532
|
};
|
|
505
533
|
}
|
|
506
|
-
return executeRewind(ctx, store, invocation, target, mode);
|
|
534
|
+
return executeRewind(ctx, store, invocation, target, mode, inflight);
|
|
507
535
|
}
|
|
508
536
|
function apply(ctx, config) {
|
|
509
537
|
const store = new SnapshotStore(config?.snapshotDir);
|
|
510
538
|
const pending = /* @__PURE__ */ new Map();
|
|
511
|
-
const anchorCache = /* @__PURE__ */ new
|
|
539
|
+
const anchorCache = /* @__PURE__ */ new WeakMap();
|
|
540
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
512
541
|
ctx.effect(function* () {
|
|
513
542
|
yield ctx.commands.register({
|
|
514
543
|
name: "rewind",
|
|
515
544
|
description: "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
|
|
516
|
-
handler: (invocation) => handleRewind(ctx, store, invocation)
|
|
545
|
+
handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
|
|
517
546
|
});
|
|
518
547
|
}, "dsh-rewind command");
|
|
519
548
|
ctx.inject(["fs"], (scope) => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* @module dsh-rewind/client/hidden
|
|
7
7
|
*/
|
|
8
|
-
import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
8
|
+
import type { ChatConversationViewNode, CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
9
9
|
/** Minimal chat snapshot reader the hiding logic needs. */
|
|
10
10
|
export interface HiddenChat {
|
|
11
11
|
readonly order: readonly string[];
|
|
@@ -15,6 +15,25 @@ export interface HiddenChat {
|
|
|
15
15
|
}
|
|
16
16
|
/** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
|
|
17
17
|
export declare function targetOfOutcome(text: string | undefined): number | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
|
|
20
|
+
* admission form the popover drives (`@<seq> chat` / `both`) that settled
|
|
21
|
+
* with a marker-carrying success outcome. The composer refill waits for
|
|
22
|
+
* exactly this node after the user confirms, so a history-loaded command can
|
|
23
|
+
* never trigger a fill.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isExecutedRewindCommand(node: CommandNode, seq: number): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a preview outcome reports tracked file changes — the availability
|
|
28
|
+
* of the "rewind conversation and code" option (Claude Code hides the
|
|
29
|
+
* code-restore options when the checkpoint has no tracked changes).
|
|
30
|
+
*
|
|
31
|
+
* Prefers the machine-readable `impact=<n>` trailer the current host appends
|
|
32
|
+
* to preview text. Older host output (or a history-loaded preview row from
|
|
33
|
+
* before the trailer existed) has none, so it falls back to the human copy
|
|
34
|
+
* ("将影响 …") to keep mixed-version deployments correct.
|
|
35
|
+
*/
|
|
36
|
+
export declare function hasFileImpact(text: string | undefined): boolean;
|
|
18
37
|
/**
|
|
19
38
|
* Anchor seqs that must be hidden from the rendered transcript so the user
|
|
20
39
|
* sees the conversation as the agent sees it: every impact-preview flow node
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @module dsh-rewind/client/popover
|
|
8
8
|
*/
|
|
9
9
|
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
10
|
+
import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
10
11
|
import type { RewindKey } from './locales.ts';
|
|
11
12
|
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
12
13
|
export interface PopoverOptions {
|
|
@@ -17,9 +18,32 @@ export interface PopoverOptions {
|
|
|
17
18
|
/** The button that opened the popover (outside-click ignore target). */
|
|
18
19
|
readonly anchor: HTMLElement;
|
|
19
20
|
readonly t: Translate;
|
|
21
|
+
/**
|
|
22
|
+
* Execute one rewind in the given mode. The popover closes itself first;
|
|
23
|
+
* the callback owns the command + composer-refill lifecycle (see
|
|
24
|
+
* runRewindAndFill in index.ts).
|
|
25
|
+
*/
|
|
26
|
+
readonly onRewind: (mode: 'chat' | 'both') => void;
|
|
20
27
|
}
|
|
21
28
|
/** Close the current popover, if any. */
|
|
22
29
|
export declare function closePopover(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Seqs of the command nodes currently matching `match`. Sample BEFORE issuing
|
|
32
|
+
* a new command of the same shape so the subsequent wait can exclude them: a
|
|
33
|
+
* repeated preview/rewind of the same target must not settle on the previous
|
|
34
|
+
* command's stale outcome (e.g. an older preview that found file changes,
|
|
35
|
+
* after those changes were already restored).
|
|
36
|
+
*/
|
|
37
|
+
export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the outcome of the newest matching rewind command by watching the
|
|
40
|
+
* session snapshot (command/run + command/done land as one CommandNode).
|
|
41
|
+
* @returns the outcome text-bearing node, or null on timeout.
|
|
42
|
+
*/
|
|
43
|
+
export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
|
|
44
|
+
kind: 'success' | 'error';
|
|
45
|
+
text?: string;
|
|
46
|
+
} | null>;
|
|
23
47
|
/** Open the mode-selection popover anchored near the given button. */
|
|
24
48
|
export declare function openPopover(opts: PopoverOptions): void;
|
|
25
49
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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,6 +23,9 @@
|
|
|
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
|
+
},
|
|
26
29
|
"exports": {
|
|
27
30
|
".": {
|
|
28
31
|
"types": "./lib/types/index.d.ts",
|
|
@@ -41,7 +44,8 @@
|
|
|
41
44
|
"README.zh.md",
|
|
42
45
|
"docs",
|
|
43
46
|
"assets",
|
|
44
|
-
"LICENSE"
|
|
47
|
+
"LICENSE",
|
|
48
|
+
"scripts/repair-markers.mjs"
|
|
45
49
|
],
|
|
46
50
|
"dsh": {
|
|
47
51
|
"bundle": {
|
|
@@ -0,0 +1,176 @@
|
|
|
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.'}`)
|