dsh-rewind-plugin 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/lib/client.js +2 -2
- package/lib/index.js +32 -13
- package/lib/types/client/hidden.d.ts +1 -1
- package/lib/types/index.d.ts +6 -6
- package/lib/types/rewind-marker-repair.d.ts +27 -8
- package/lib/types/rewind.d.ts +5 -7
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -96,8 +96,8 @@ The whole design rests on two principles, simple but deliberate: **the conversat
|
|
|
96
96
|
|
|
97
97
|
`append-only` is a hard rule: the session log only grows and is never rewritten — the foundation of auditability and privacy. A rewind never touches history; it makes a single move: append **one content-empty marker message** to the end of the log and use it to "mask + replace" everything after the target message, so the model and the UI see only the part before it.
|
|
98
98
|
|
|
99
|
-
- The marker is **canonical** — the plugin **replicates** `/compact`'s "hide + replace": `/compact` compresses a span of history into a summary, while `/rewind` swaps in an empty
|
|
100
|
-
- The replacement is **
|
|
99
|
+
- The marker is **canonical** — the plugin **replicates** `/compact`'s "hide + replace": `/compact` compresses a span of history into a summary, while `/rewind` swaps in an empty marker message. Because it's canonical, the harness's own log replay, `/compact` compaction, and resume preflight all recognize it and never mistake it for a real message.
|
|
100
|
+
- The replacement is **imperceptible** — the model ignores the empty marker, with no effect (verified empirically). Together with the plugin's UI handling, what you and the model see is exactly how the conversation looked at the target — truly "in place".
|
|
101
101
|
- Because this is **masking, not deleting**, every withdrawn event stays in the log — auditable, traceable, viewable, and in principle manually recoverable.
|
|
102
102
|
|
|
103
103
|
> **Design highlight**: the entire conversation rewind is **a single append**. It's deterministic, auditable, and — because the log was never broken — a "clean" time-travel. Minimal action, complete semantics. The compatibility subtleties with the harness (replicating `/compact`, the empty-message mask) are where this plugin is genuinely professional — each is pinned by a dedicated probe test.
|
package/README.md
CHANGED
|
@@ -86,8 +86,8 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
86
86
|
|
|
87
87
|
`append-only` 是铁律:会话日志只追加、从不改写——这是可审计与信息安全的地基。回退从不动历史,它只做一步:往日志末尾追加一条**内容为空的标记消息**,把目标消息之后的全部内容「遮蔽 + 替换」掉,让模型和界面都只看得到目标之前的部分。
|
|
88
88
|
|
|
89
|
-
- **标记是规范的**——插件复刻 `/compact` 标准的「隐藏 + 替换」:`/compact` 把一段历史压缩成摘要,`/rewind`
|
|
90
|
-
-
|
|
89
|
+
- **标记是规范的**——插件复刻 `/compact` 标准的「隐藏 + 替换」:`/compact` 把一段历史压缩成摘要,`/rewind` 则换成一条空标记消息。由于其规范性,harness 的日志重放、`/compact` 压缩、续接检查都能正确识别它,绝不会把它误认为真实对话;
|
|
90
|
+
- **替换内容无感**——模型对空标记忽略、无感(实测验证)。配合插件对界面显示的处理,模型和你看到的对话就是目标消息当时的样子,真正的「就地」;
|
|
91
91
|
- 因为是「遮蔽」而非「删除」,**被撤回的每一条事件都完整留在日志里**,可审计、可追溯、可查看,原则上也随时能手动恢复。
|
|
92
92
|
|
|
93
93
|
> **设计点睛**:整个对话回退就是**一条**追加。它确定、可审计,且因为日志从未被破坏,回溯是「干净的」——用最小的动作,实现最完整的语义。那些与 harness 内部的兼容细节(对 `/compact` 的复刻、空消息的遮蔽)正是插件的专业所在,每一条都由专门的探针测试固化。
|
package/lib/client.js
CHANGED
|
@@ -1333,8 +1333,8 @@ function createRewindBridge(deps) {
|
|
|
1333
1333
|
}
|
|
1334
1334
|
|
|
1335
1335
|
// src/client/build-info.ts
|
|
1336
|
-
var PLUGIN_VERSION = true ? "0.9.
|
|
1337
|
-
var BUILD_HASH = true ? "
|
|
1336
|
+
var PLUGIN_VERSION = true ? "0.9.1" : "dev";
|
|
1337
|
+
var BUILD_HASH = true ? "39613bb7" : "dev";
|
|
1338
1338
|
|
|
1339
1339
|
// src/client/locales.ts
|
|
1340
1340
|
var zh = {
|
package/lib/index.js
CHANGED
|
@@ -51,7 +51,7 @@ var en = {
|
|
|
51
51
|
"cleanup.clearCancelled": "Clear cancelled.",
|
|
52
52
|
"cleanup.clearFailed": "Could not clear session {sessionId}: {detail}.",
|
|
53
53
|
"cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply\n /snapshot-auto-cleanup run --current [--apply] dry-run/clear this session's snapshots",
|
|
54
|
-
"rewindfix.description": "Update legacy rewind markers (A/B) in closed sessions to
|
|
54
|
+
"rewindfix.description": "Update legacy rewind markers (A/B) in closed sessions to form-C, and fill old empty form-C content with the (empty message) placeholder",
|
|
55
55
|
"rewindfix.inputHint": "no args = dry-run preview; --apply = execute (takes minutes)",
|
|
56
56
|
"rewindfix.dryRun": "=== dry-run: {scan} sessions scanned / {will} will be updated / {skip} skipped / {fail} failed ({secs}s) ===",
|
|
57
57
|
"rewindfix.done": "=== done: {repair} updated / {skip} skipped / {fail} failed ({secs}s) ===",
|
|
@@ -119,7 +119,7 @@ var zh = {
|
|
|
119
119
|
"cleanup.clearCancelled": "\u6E05\u7A7A\u5DF2\u53D6\u6D88\u3002",
|
|
120
120
|
"cleanup.clearFailed": "\u65E0\u6CD5\u6E05\u9664\u4F1A\u8BDD {sessionId}\uFF1A{detail}\u3002",
|
|
121
121
|
"cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C\n /snapshot-auto-cleanup run --current [--apply] \u9884\u6F14/\u6E05\u9664\u672C\u4F1A\u8BDD\u5FEB\u7167",
|
|
122
|
-
"rewindfix.description": "\u5C06\u5DF2\u5173\u95ED\u4F1A\u8BDD\u4E2D\u7684\u65E7\u7248 rewind \u6807\u8BB0\uFF08A/B\uFF09\u66F4\u65B0\u4E3A\
|
|
122
|
+
"rewindfix.description": "\u5C06\u5DF2\u5173\u95ED\u4F1A\u8BDD\u4E2D\u7684\u65E7\u7248 rewind \u6807\u8BB0\uFF08A/B\uFF09\u66F4\u65B0\u4E3A form-C\uFF0C\u5E76\u628A\u65E7\u7684\u7A7A form-C \u5185\u5BB9\u8865\u6210 (empty message) \u5360\u4F4D",
|
|
123
123
|
"rewindfix.inputHint": "\u65E0\u53C2 = dry-run \u9884\u89C8\uFF1B--apply = \u6267\u884C\uFF08\u9700\u6570\u5206\u949F\uFF09",
|
|
124
124
|
"rewindfix.dryRun": "=== \u9884\u6F14\uFF1A\u626B\u63CF {scan} \u4E2A\u4F1A\u8BDD / \u5C06\u66F4\u65B0 {will} \u4E2A / \u8DF3\u8FC7 {skip} \u4E2A / \u5931\u8D25 {fail} \u4E2A\uFF08{secs}s\uFF09===",
|
|
125
125
|
"rewindfix.done": "=== \u5B8C\u6210\uFF1A\u66F4\u65B0 {repair} \u4E2A / \u8DF3\u8FC7 {skip} \u4E2A / \u5931\u8D25 {fail} \u4E2A\uFF08{secs}s\uFF09===",
|
|
@@ -1344,7 +1344,10 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
|
|
|
1344
1344
|
|
|
1345
1345
|
// src/rewind-marker-repair.ts
|
|
1346
1346
|
var REWIND_MARKER_SOURCE = { kind: "plugin", plugin: "dsh-rewind" };
|
|
1347
|
-
var REWIND_MARKER_CONTENT = [];
|
|
1347
|
+
var REWIND_MARKER_CONTENT = [{ type: "text", text: "(empty message)" }];
|
|
1348
|
+
function isCanonicalMarkerContent(content) {
|
|
1349
|
+
return Array.isArray(content) && content.length === 1 && content[0]?.type === "text" && content[0]?.text === "(empty message)";
|
|
1350
|
+
}
|
|
1348
1351
|
function buildRewindMarkerData(id) {
|
|
1349
1352
|
return {
|
|
1350
1353
|
role: "user",
|
|
@@ -1365,6 +1368,7 @@ function isFormCMarker(event) {
|
|
|
1365
1368
|
function repairRewindMarkers(input) {
|
|
1366
1369
|
const rows = input.map(toRow);
|
|
1367
1370
|
const stats = { a: 0, b: 0, c: 0, removedGhosts: 0 };
|
|
1371
|
+
let contentUpgrades = 0;
|
|
1368
1372
|
for (const row of rows) {
|
|
1369
1373
|
if (isFormCMarkerRow(row)) stats.c += 1;
|
|
1370
1374
|
}
|
|
@@ -1392,7 +1396,12 @@ function repairRewindMarkers(input) {
|
|
|
1392
1396
|
const original = rows[i];
|
|
1393
1397
|
const newSeq = survivors.length;
|
|
1394
1398
|
oldToNew.set(original.seq, newSeq);
|
|
1395
|
-
|
|
1399
|
+
if (!isRewindMarkerRow(original) || isCanonicalCMarker(original)) {
|
|
1400
|
+
survivors.push(toEvent({ ...original, seq: newSeq }));
|
|
1401
|
+
} else {
|
|
1402
|
+
if (isFormCMarkerRow(original)) contentUpgrades += 1;
|
|
1403
|
+
survivors.push(toEvent(retargetToC(original, newSeq)));
|
|
1404
|
+
}
|
|
1396
1405
|
}
|
|
1397
1406
|
const mapSeq = (old) => {
|
|
1398
1407
|
const mapped = oldToNew.get(old);
|
|
@@ -1402,21 +1411,31 @@ function repairRewindMarkers(input) {
|
|
|
1402
1411
|
return mapped;
|
|
1403
1412
|
};
|
|
1404
1413
|
const events = survivors.map((event) => toEvent(remapReferences(toRow(event), mapSeq)));
|
|
1405
|
-
return { events, mapSeq: oldToNew, stats };
|
|
1414
|
+
return { events, mapSeq: oldToNew, stats, contentUpgrades };
|
|
1415
|
+
}
|
|
1416
|
+
function rowSource(row) {
|
|
1417
|
+
const message = row.data["message"];
|
|
1418
|
+
return message?.["source"] ?? row.data["source"];
|
|
1406
1419
|
}
|
|
1407
1420
|
function isLegacyRewindMarkerRow(row) {
|
|
1408
1421
|
if (row.type !== "assistant/message") return false;
|
|
1409
|
-
const
|
|
1410
|
-
const source = message?.["source"];
|
|
1422
|
+
const source = rowSource(row);
|
|
1411
1423
|
if (source?.["kind"] !== "model") return false;
|
|
1412
1424
|
if (source?.["provider"] !== "dsh-rewind" || source?.["model"] !== "rewind-marker") return false;
|
|
1413
1425
|
return isReplaceSurfaceOp(row.surfaceOp);
|
|
1414
1426
|
}
|
|
1415
1427
|
function isFormCMarkerRow(row) {
|
|
1416
|
-
if (row.type !== "user/message") return false;
|
|
1417
|
-
|
|
1428
|
+
if (row.type !== "user/message" && row.type !== "assistant/message") return false;
|
|
1429
|
+
if (!isReplaceSurfaceOp(row.surfaceOp)) return false;
|
|
1430
|
+
const source = rowSource(row);
|
|
1418
1431
|
return source?.["kind"] === "plugin" && source?.["plugin"] === "dsh-rewind";
|
|
1419
1432
|
}
|
|
1433
|
+
function isRewindMarkerRow(row) {
|
|
1434
|
+
return isLegacyRewindMarkerRow(row) || isFormCMarkerRow(row);
|
|
1435
|
+
}
|
|
1436
|
+
function isCanonicalCMarker(row) {
|
|
1437
|
+
return row.type === "user/message" && isFormCMarkerRow(row) && isCanonicalMarkerContent(row.data["content"]);
|
|
1438
|
+
}
|
|
1420
1439
|
function asRow(event) {
|
|
1421
1440
|
const e = event;
|
|
1422
1441
|
return {
|
|
@@ -1446,7 +1465,7 @@ function toEvent(row) {
|
|
|
1446
1465
|
}
|
|
1447
1466
|
function retargetToC(row, seq) {
|
|
1448
1467
|
const message = row.data["message"];
|
|
1449
|
-
const id = typeof message?.["id"] === "string" ? message["id"] : void 0;
|
|
1468
|
+
const id = typeof message?.["id"] === "string" ? message["id"] : typeof row.data["id"] === "string" ? row.data["id"] : void 0;
|
|
1450
1469
|
return {
|
|
1451
1470
|
type: "user/message",
|
|
1452
1471
|
seq,
|
|
@@ -1687,7 +1706,8 @@ async function processOneSession(deps, opts, header) {
|
|
|
1687
1706
|
const b = converted.stats.b;
|
|
1688
1707
|
const c = converted.stats.a + converted.stats.b + converted.stats.c;
|
|
1689
1708
|
const staleArgs = stale.fixed;
|
|
1690
|
-
const
|
|
1709
|
+
const contentUpgrades = converted.contentUpgrades;
|
|
1710
|
+
const needsRepair = a + b > 0 || staleArgs > 0 || contentUpgrades > 0;
|
|
1691
1711
|
if (!needsRepair) {
|
|
1692
1712
|
return { id, status: "skipped", a, b, c, staleArgs, reason: "no-markers" };
|
|
1693
1713
|
}
|
|
@@ -2119,10 +2139,9 @@ async function commitEntry(store, pending, anchorCache, trackedBySession, exec,
|
|
|
2119
2139
|
}
|
|
2120
2140
|
tracked.add(capture.path);
|
|
2121
2141
|
}
|
|
2122
|
-
var REWIND_MARKER_CONTENT2 = [];
|
|
2123
2142
|
function buildMarker() {
|
|
2124
2143
|
return createUserMessage({
|
|
2125
|
-
content:
|
|
2144
|
+
content: REWIND_MARKER_CONTENT,
|
|
2126
2145
|
source: REWIND_MARKER_SOURCE
|
|
2127
2146
|
});
|
|
2128
2147
|
}
|
|
@@ -104,7 +104,7 @@ export declare function isCandidateCommand(command: CommandNode): boolean;
|
|
|
104
104
|
* (pending, succeeded, or errored — it only exists to feed the popover) and
|
|
105
105
|
* every SUCCESSFUL executed `/rewind` command row, plus every message
|
|
106
106
|
* withdrawn by a rewind — the target message itself, everything after it, and
|
|
107
|
-
* the (
|
|
107
|
+
* the (unrendered) marker.
|
|
108
108
|
*
|
|
109
109
|
* Each executed rewind cuts ONE span `[target, marker]`: the target message
|
|
110
110
|
* and everything after it, up to the marker appended at rewind time. Spans are
|
package/lib/types/index.d.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
* surface node after the target message with the marker. The append-only log
|
|
9
9
|
* (and the rendered transcript) is untouched — only the model-visible surface
|
|
10
10
|
* is cut, so the next request derives its context from the target onward.
|
|
11
|
-
* The marker is
|
|
12
|
-
*
|
|
13
|
-
* `assistant/message` can no longer carry those — so
|
|
14
|
-
* a `user/message`, exactly as /compact's checkpoint
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* The marker is a `user/message` carrying the shadowed-seq citations
|
|
12
|
+
* (`sourceEventSeqs`): v2 reserves surface `replace` to a node that cites
|
|
13
|
+
* every shadowed seq, and `assistant/message` can no longer carry those — so
|
|
14
|
+
* the replacement node is a `user/message`, exactly as /compact's checkpoint
|
|
15
|
+
* is. It derives to itself (a present user turn), so the marker stays as the
|
|
16
|
+
* surface-tail cut point rather than vanishing.
|
|
17
17
|
*
|
|
18
18
|
*
|
|
19
19
|
* File restore (mode `both`) follows Claude Code's checkpointing: the plugin
|
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* The `/dsh-rewind-fix` command rewrites the OLD plugin's rewind marker — a
|
|
5
5
|
* bare `assistant/message(turn=N, step=0)` (form A) or a ghost-frame
|
|
6
6
|
* `[step/start][assistant/message][step/end]` inside a closed turn (form B) —
|
|
7
|
-
* into the CURRENT marker shape (form C):
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* into the CURRENT marker shape (form C): a `user/message` whose `source` cites
|
|
8
|
+
* the plugin, whose `surfaceOp`/`sourceEventSeqs` still carry the surface
|
|
9
|
+
* replace, and whose content is the constant `(empty message)` placeholder
|
|
10
|
+
* (never empty, so a strict gateway does not reject it). Form C is what a 0.1.3
|
|
11
|
+
* harness accepts. It ALSO upgrades an older form-C marker whose content was
|
|
12
|
+
* empty to the canonical placeholder.
|
|
10
13
|
*
|
|
11
14
|
* This module is PURE: it only recognizes event shapes and rewrites them. It
|
|
12
15
|
* does zero IO and has no dependency on the harness session services. The
|
|
@@ -36,13 +39,23 @@ export declare const REWIND_MARKER_SOURCE: {
|
|
|
36
39
|
readonly kind: 'plugin';
|
|
37
40
|
readonly plugin: 'dsh-rewind';
|
|
38
41
|
};
|
|
39
|
-
/**
|
|
40
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The rewind-marker message content: always this minimal self-declaring
|
|
44
|
+
* placeholder. It is provider-independent — a strict OpenAI-compatible gateway
|
|
45
|
+
* rejects an empty user message (HTTP 400, Issue #21), and the session log is
|
|
46
|
+
* immutable while the model serving it may change. A constant non-empty
|
|
47
|
+
* placeholder is accepted by every gate and reads as an empty one the model
|
|
48
|
+
* need not act on.
|
|
49
|
+
*/
|
|
50
|
+
export declare const REWIND_MARKER_CONTENT: ContentBlock[];
|
|
51
|
+
/** Whether a form-C marker's content is already the canonical placeholder. */
|
|
52
|
+
export declare function isCanonicalMarkerContent(content: unknown): boolean;
|
|
41
53
|
/**
|
|
42
54
|
* Build the form-C marker `data` (the `user/message` payload). When `id` is
|
|
43
55
|
* supplied it is preserved verbatim (the repair keeps the original marker's
|
|
44
|
-
* `message.id`); when omitted a
|
|
45
|
-
* reuse this contract for its
|
|
56
|
+
* id, whether nested at `data.message.id` or at `data.id`); when omitted a
|
|
57
|
+
* fresh id is generated so the running plugin can reuse this contract for its
|
|
58
|
+
* live `buildMarker()`.
|
|
46
59
|
*/
|
|
47
60
|
export declare function buildRewindMarkerData(id?: string): Record<string, unknown>;
|
|
48
61
|
/** The surface `replace` op carried by a rewind marker. */
|
|
@@ -55,7 +68,11 @@ export interface ReplaceSurfaceOp {
|
|
|
55
68
|
export declare function isReplaceSurfaceOp(value: unknown): value is ReplaceSurfaceOp;
|
|
56
69
|
/** Is `event` a legacy rewind marker in either form A or B? */
|
|
57
70
|
export declare function isLegacyRewindMarker(event: Readonly<SessionEvent>): boolean;
|
|
58
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Is `event` already a form-C rewind marker (a `user/message`, or a re-typed
|
|
73
|
+
* `assistant/message`, carrying the dsh-rewind plugin source + replace op)? the
|
|
74
|
+
* target shape.
|
|
75
|
+
*/
|
|
59
76
|
export declare function isFormCMarker(event: Readonly<SessionEvent>): boolean;
|
|
60
77
|
/** Result of {@link repairRewindMarkers}. */
|
|
61
78
|
export interface RepairOutput {
|
|
@@ -70,6 +87,8 @@ export interface RepairOutput {
|
|
|
70
87
|
c: number;
|
|
71
88
|
removedGhosts: number;
|
|
72
89
|
};
|
|
90
|
+
/** Form-C markers whose content was upgraded to the canonical placeholder. */
|
|
91
|
+
contentUpgrades: number;
|
|
73
92
|
}
|
|
74
93
|
/**
|
|
75
94
|
* Rewrite any number of stacked rewind markers (forms A and B, interleaved)
|
package/lib/types/rewind.d.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
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.1.3/v2): the marker is
|
|
13
|
-
*
|
|
12
|
+
* Marker shape (v0.1.3/v2): the marker is a `user/message` carrying a replace
|
|
13
|
+
* `surfaceOp` — a single event:
|
|
14
14
|
*
|
|
15
|
-
* user/message (marker
|
|
15
|
+
* user/message (marker content) → { surfaceOp {replace, start, end} }
|
|
16
16
|
*
|
|
17
17
|
* v2 reserves surface `replace` to a node that cites every shadowed seq via
|
|
18
18
|
* `sourceEventSeqs`, and `assistant/message` can no longer carry
|
|
@@ -21,10 +21,8 @@
|
|
|
21
21
|
* is. No ghost `step/start`…`step/end` frame is needed: the token-meter's
|
|
22
22
|
* step state machine ignores `user/message`, and the session invariant
|
|
23
23
|
* (`invariant.ts`) imposes no open-turn requirement on it, so the marker is
|
|
24
|
-
* appended while idle, outside any turn.
|
|
25
|
-
*
|
|
26
|
-
* "cut point" (an empty `user/message` derives to itself, so it remains a
|
|
27
|
-
* present-but-empty user turn in derived history).
|
|
24
|
+
* appended while idle, outside any turn. It sits at the surface tail as the
|
|
25
|
+
* model-visible "cut point" — a present user turn in derived history.
|
|
28
26
|
*
|
|
29
27
|
* @module dsh-rewind/rewind
|
|
30
28
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "DSH 插件:真正便捷无感的同窗口内对话回退,从不新建分支;自带轻量工作区备份,可一并还原文件(完整 Claude Code /rewind 语义)。 · DSH plugin: genuinely effortless in-window conversation rewind — never forking a new session; ships a lightweight workspace backup that restores files together with the rewind (full Claude Code /rewind semantics).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|