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 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 user 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 **empty** — the model fully ignores the empty message, with no effect (verified theoretically and 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".
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` 则换成一条空用户消息。由于其规范性,harness 的日志重放、`/compact` 压缩、续接检查都能正确识别它,绝不会把它误认为真实对话;
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.0" : "dev";
1337
- var BUILD_HASH = true ? "ca24fc4e" : "dev";
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 the current form-C shape",
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\u5F53\u524D form-C \u5F62\u6001",
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
- survivors.push(isLegacyRewindMarkerRow(original) ? toEvent(retargetToC(original, newSeq)) : toEvent({ ...original, seq: newSeq }));
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 message = row.data["message"];
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
- const source = row.data["source"];
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 needsRepair = a + b > 0 || staleArgs > 0;
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: REWIND_MARKER_CONTENT2,
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 (empty, unrendered) marker.
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
@@ -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 an EMPTY `user/message`: v2 reserves surface `replace` to a
12
- * node that cites every shadowed seq (`sourceEventSeqs`), and
13
- * `assistant/message` can no longer carry those — so the replacement node is
14
- * a `user/message`, exactly as /compact's checkpoint is. An empty
15
- * `user/message` derives to itself (a present-but-empty user turn), so the
16
- * marker stays as the surface-tail cut point rather than vanishing.
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): an empty-content `user/message` whose
8
- * `source` cites the plugin and whose `surfaceOp`/`sourceEventSeqs` still carry
9
- * the surface replace. Form C is what a 0.1.3 harness accepts.
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
- /** The rewind-marker message content: empty (closest to "invisible"). */
40
- export declare const REWIND_MARKER_CONTENT: readonly ContentBlock[];
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 fresh id is generated so the running plugin can
45
- * reuse this contract for its live `buildMarker()`.
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
- /** Is `event` already a form-C rewind marker? (the target shape) */
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)
@@ -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 an EMPTY `user/message` carrying a
13
- * replace `surfaceOp` — a single event:
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, empty content) → { surfaceOp {replace, start, end} }
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. The empty content means the marker
25
- * carries no language; it sits at the surface tail as the model-visible
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.0",
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",