dsh-rewind-plugin 0.1.0 → 0.1.2
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 +20 -9
- package/lib/client.js +1 -1
- package/lib/index.js +13 -8
- package/lib/types/client/index.d.ts +28 -0
- package/lib/types/client/locales.d.ts +43 -0
- package/lib/types/client/popover.d.ts +25 -0
- package/lib/types/client/styles.d.ts +27 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/ledger.d.ts +88 -0
- package/lib/types/rewind.d.ts +94 -0
- package/lib/types/session-cwd.d.ts +29 -0
- package/package.json +44 -15
- package/scripts/build.mjs +10 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 插件:**同一会话窗口的 in-place 对话回退**(Claude Code `/rewind` 语义)。主交互为**用户消息旁的「回退」按钮**,点击后选择回退模式;命令仅作辅助。
|
|
4
4
|
|
|
5
|
-
> 状态:v0.1.
|
|
5
|
+
> 状态:v0.1.2 已实现并发布(`dsh-rewind-plugin`,npm + GitHub Actions Trusted Publishing)。交互以 Claude Code 行为为参考,并贴合 dsh Web 实际 UI(利用现有 DOM 锚点与运行时快照,纯插件、不改仓库核心)。
|
|
6
6
|
|
|
7
|
-
## 实现状态(v0.1.
|
|
7
|
+
## 实现状态(v0.1.2)
|
|
8
8
|
|
|
9
9
|
- ✅ host 端 `/rewind` 命令(两步文本引导 + 直接执行 + `preview` 影响清单)
|
|
10
10
|
- ✅ host 端变更台账(`tools/execute` 捕获 before、`tools/post-execute` 提交),按会话隔离
|
|
@@ -39,7 +39,8 @@ dsh plugin --profile web add dsh-rewind-plugin
|
|
|
39
39
|
|
|
40
40
|
## 发布(维护者,CI + Trusted Publishing)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
已发布版本:`0.1.0`(本地 2FA 首发)、`0.1.1`(CI OIDC + Sigstore provenance)。
|
|
43
|
+
后续发版走 CI:
|
|
43
44
|
|
|
44
45
|
```sh
|
|
45
46
|
npm version patch # 或 minor / major;同步更新 README 版本说明
|
|
@@ -52,17 +53,24 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
52
53
|
GitHub Release。
|
|
53
54
|
- **一次性 npm 侧配置**(仓库内无法代做;配置**不需要包先存在**,发布后
|
|
54
55
|
`npmjs.com/package/dsh-rewind-plugin` 才会出现):
|
|
55
|
-
1.
|
|
56
|
-
|
|
57
|
-
2. **
|
|
58
|
-
|
|
56
|
+
1. 打开 [dsh-rewind-plugin 包页](https://www.npmjs.com/package/dsh-rewind-plugin)
|
|
57
|
+
→ 右上角 **settings** → **Trusted Publisher** → **Add**;
|
|
58
|
+
2. 字段:Provider **GitHub Actions** · Organization or user **`SiriLee`** ·
|
|
59
|
+
Repository **`dsh-rewind`**(GitHub 仓库名,与 npm 包名可不同)·
|
|
60
|
+
Workflow filename **`publish.yml`**(只填文件名)· Environment **留空**
|
|
61
|
+
(本 workflow 无 `environment` 块,OIDC subject 携带 ref,此为已验证组合)·
|
|
62
|
+
**Allowed actions:`npm publish`**(2026-05-20 起必选)。
|
|
59
63
|
- 质量门禁(PR / push main):`.github/workflows/ci.yml` 跑 typecheck + 测试
|
|
60
|
-
+ 构建 + `verify-host
|
|
64
|
+
+ 构建 + `verify-host` + tarball 完整性检查(`lib/` 与 `LICENSE` 必须在包内)。
|
|
65
|
+
- 包内附带 `.d.ts` 类型声明(`exports` 已声明 `types`,源码级 `./src/*` 亦可导入)。
|
|
61
66
|
|
|
62
67
|
## 使用
|
|
63
68
|
|
|
64
69
|
- 每条用户消息 hover 出现「↶ 回退」按钮:点击 → 选择「仅回退对话」或
|
|
65
70
|
「回退对话和代码」(后者先展示影响清单再确认)。
|
|
71
|
+
- **撤回最近消息**:对**最后一条**用户消息回退(按钮或 `/rewind <目标>`),效果是
|
|
72
|
+
**撤回该消息本身**(发错了立即重发的常见场景)——上下文回到它之前,命令结果
|
|
73
|
+
提示"已撤回 seq N,可重新发送"。
|
|
66
74
|
- 键盘流:`/rewind` → 选消息 → `/rewind <序号> chat|both`;`/rewind preview <目标>`
|
|
67
75
|
只输出影响清单不执行。
|
|
68
76
|
- 回退后:模型上下文从目标消息重新开始;会话日志与可见对话完整保留
|
|
@@ -71,10 +79,13 @@ git push --tags # push v<version> tag → 触发 .github/workflows/publish.
|
|
|
71
79
|
## 已知限制(v0.1)
|
|
72
80
|
|
|
73
81
|
- 台账只覆盖插件运行期间、经 `write` / `edit` / `str_replace_editor` 的变更;
|
|
74
|
-
bash 或外部程序的修改无法还原(二期可加 git-first
|
|
82
|
+
bash 或外部程序的修改无法还原(二期可加 git-first 快照层)。台账按会话有界
|
|
83
|
+
(每会话保留最近 2000 条,最旧先丢弃)。
|
|
75
84
|
- 文件删除走 `processPath` 直删(本地 backend);sandbox/远程 backend 下还原
|
|
76
85
|
可能受限。
|
|
77
86
|
- 回退本身可再回退(标记进入日志),但文件还原动作不再重新入台账。
|
|
87
|
+
- 回退按钮只出现在**当前会话**渲染的用户消息行上(DOM 注入范围即当前视图);
|
|
88
|
+
subagent/分屏等非当前会话的对话需要先切到该会话再回退。
|
|
78
89
|
|
|
79
90
|
## 背景与定位
|
|
80
91
|
|
package/lib/client.js
CHANGED
|
@@ -298,7 +298,7 @@ function renderImpactStep(root, opts, back) {
|
|
|
298
298
|
session,
|
|
299
299
|
(node) => {
|
|
300
300
|
const args = node.args ?? "";
|
|
301
|
-
return node.name === "rewind" && args.includes("preview") &&
|
|
301
|
+
return node.name === "rewind" && args.includes("preview") && new RegExp(`(?:^|\\s)@${seq}(?=\\s|$)`).test(args);
|
|
302
302
|
}
|
|
303
303
|
);
|
|
304
304
|
if (outcome === null) {
|
package/lib/index.js
CHANGED
|
@@ -14,11 +14,13 @@ function execSessionCwd(exec, requestedPath) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
// src/ledger.ts
|
|
17
|
+
var MAX_LEDGER_ENTRIES = 2e3;
|
|
17
18
|
var RewindLedger = class {
|
|
18
19
|
entries = [];
|
|
19
|
-
/** Record one committed mutation. */
|
|
20
|
+
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
20
21
|
record(entry) {
|
|
21
22
|
this.entries.push(entry);
|
|
23
|
+
if (this.entries.length > MAX_LEDGER_ENTRIES) this.entries.shift();
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
@@ -168,10 +170,13 @@ function planRewind(events, surface, target) {
|
|
|
168
170
|
);
|
|
169
171
|
}
|
|
170
172
|
if (targetIndex === surface.length - 1) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
return {
|
|
174
|
+
targetSeq,
|
|
175
|
+
targetIndex,
|
|
176
|
+
shadowedSeqs: [targetSeq],
|
|
177
|
+
surfaceStart: targetSeq,
|
|
178
|
+
surfaceEnd: targetSeq
|
|
179
|
+
};
|
|
175
180
|
}
|
|
176
181
|
const shadowedSeqs = surface.slice(targetIndex + 1);
|
|
177
182
|
return {
|
|
@@ -351,9 +356,10 @@ async function executeRewind(ctx, ledger, invocation, rawTarget, mode) {
|
|
|
351
356
|
restore = `\uFF1B\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6\u3001\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6${renderFailures(outcome.failed)}`;
|
|
352
357
|
}
|
|
353
358
|
}
|
|
359
|
+
const withdrewLatest = plan.shadowedSeqs[0] === plan.targetSeq;
|
|
354
360
|
return {
|
|
355
361
|
kind: "success",
|
|
356
|
-
text: `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
|
|
362
|
+
text: withdrewLatest ? `\u5DF2\u64A4\u56DE seq ${plan.targetSeq}\uFF08\u6700\u8FD1\u4E00\u6761\u6D88\u606F\uFF09\uFF0C\u53EF\u91CD\u65B0\u53D1\u9001${restore}\u3002` : `\u5DF2\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u79FB\u9664 ${plan.shadowedSeqs.length} \u6761\u4E0A\u4E0B\u6587\uFF08\u65E5\u5FD7\u4FDD\u7559\uFF09${restore}\u3002`,
|
|
357
363
|
sourceEventSeq: event.seq
|
|
358
364
|
};
|
|
359
365
|
}
|
|
@@ -363,8 +369,7 @@ function rewindErrorResult(error) {
|
|
|
363
369
|
"no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
|
|
364
370
|
"invalid-index": error.message,
|
|
365
371
|
"not-a-user-message": error.message,
|
|
366
|
-
"not-on-surface": error.message
|
|
367
|
-
"nothing-after": error.message
|
|
372
|
+
"not-on-surface": error.message
|
|
368
373
|
}[error.code];
|
|
369
374
|
return { kind: "error", text };
|
|
370
375
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-rewind client half: the per-user-message ↶ rewind button and the
|
|
3
|
+
* mode-selection popover, injected into the conversation DOM (pure plugin —
|
|
4
|
+
* no harness source patches).
|
|
5
|
+
*
|
|
6
|
+
* Anchoring: each chat node seat renders `[data-chat-flow-kind]` with
|
|
7
|
+
* `data-chat-anchor-key`; a `MutationObserver` tracks newly rendered user
|
|
8
|
+
* seats and appends the rewind button into the message's IconActions row.
|
|
9
|
+
* The seq is never parsed from DOM text — the key is looked up in the runtime
|
|
10
|
+
* snapshot (`session.getSnapshot().chat.nodes.get(key)`) to get the durable
|
|
11
|
+
* `UserMessageNode.seq`.
|
|
12
|
+
*
|
|
13
|
+
* Interaction: clicking a message's button fixes the target (step one), the
|
|
14
|
+
* popover offers the two modes (step two); "rewind conversation and code"
|
|
15
|
+
* first fetches the impact list via `/rewind preview @seq both` and confirms
|
|
16
|
+
* before executing. Execution always goes through `session.command(...)`, the
|
|
17
|
+
* same host path the `/rewind` command uses.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-rewind/client
|
|
20
|
+
*/
|
|
21
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
22
|
+
export declare const name = "dsh-rewind";
|
|
23
|
+
export declare const inject: string[];
|
|
24
|
+
/**
|
|
25
|
+
* Client plugin body: button injection + popover wiring.
|
|
26
|
+
* @param ctx - client root context carrying `sessions` and `locale`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** `rewind` namespace dictionaries for the client plugin. */
|
|
2
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
3
|
+
export declare const zh: {
|
|
4
|
+
'button.aria': string;
|
|
5
|
+
'button.title': string;
|
|
6
|
+
'popover.title': string;
|
|
7
|
+
'popover.target': string;
|
|
8
|
+
'popover.chat': string;
|
|
9
|
+
'popover.chat.hint': string;
|
|
10
|
+
'popover.both': string;
|
|
11
|
+
'popover.both.hint': string;
|
|
12
|
+
'popover.cancel': string;
|
|
13
|
+
'popover.impact.loading': string;
|
|
14
|
+
'popover.impact.failed': string;
|
|
15
|
+
'popover.impact.none': string;
|
|
16
|
+
'popover.confirm': string;
|
|
17
|
+
'popover.back': string;
|
|
18
|
+
};
|
|
19
|
+
/** The rewind namespace key union. */
|
|
20
|
+
export type RewindKey = keyof typeof zh;
|
|
21
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
22
|
+
interface LocaleNamespaceMap {
|
|
23
|
+
/** The in-place rewind controls' copy. */
|
|
24
|
+
rewind: RewindKey;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
28
|
+
export declare const en: {
|
|
29
|
+
'button.aria': string;
|
|
30
|
+
'button.title': string;
|
|
31
|
+
'popover.title': string;
|
|
32
|
+
'popover.target': string;
|
|
33
|
+
'popover.chat': string;
|
|
34
|
+
'popover.chat.hint': string;
|
|
35
|
+
'popover.both': string;
|
|
36
|
+
'popover.both.hint': string;
|
|
37
|
+
'popover.cancel': string;
|
|
38
|
+
'popover.impact.loading': string;
|
|
39
|
+
'popover.impact.failed': string;
|
|
40
|
+
'popover.impact.none': string;
|
|
41
|
+
'popover.confirm': string;
|
|
42
|
+
'popover.back': string;
|
|
43
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rewind mode-selection popover (plain DOM, no React). Step two of the
|
|
3
|
+
* interaction: the target is already fixed (the clicked message); the popover
|
|
4
|
+
* offers the two modes. Choosing "both" first fetches the impact list through
|
|
5
|
+
* the `/rewind preview @seq both` command and shows it before confirming.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-rewind/client/popover
|
|
8
|
+
*/
|
|
9
|
+
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
|
|
10
|
+
import type { RewindKey } from './locales.ts';
|
|
11
|
+
type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
|
|
12
|
+
export interface PopoverOptions {
|
|
13
|
+
readonly session: SessionFace;
|
|
14
|
+
readonly seq: number;
|
|
15
|
+
readonly time: number;
|
|
16
|
+
readonly preview: string;
|
|
17
|
+
/** The button that opened the popover (outside-click ignore target). */
|
|
18
|
+
readonly anchor: HTMLElement;
|
|
19
|
+
readonly t: Translate;
|
|
20
|
+
}
|
|
21
|
+
/** Close the current popover, if any. */
|
|
22
|
+
export declare function closePopover(): void;
|
|
23
|
+
/** Open the mode-selection popover anchored near the given button. */
|
|
24
|
+
export declare function openPopover(opts: PopoverOptions): void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client plugin styling: one injected `<style>` tag (scoped class names),
|
|
3
|
+
* following the dsh design tokens (`--dsw-*`) so the button and popover blend
|
|
4
|
+
* with the conversation chrome.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-rewind/client/styles
|
|
7
|
+
*/
|
|
8
|
+
/** Marker attribute set on a seat row once its rewind button is attached. */
|
|
9
|
+
export declare const REWIND_ATTACHED = "data-dsh-rewind-attached";
|
|
10
|
+
/** Class names shared between the injected DOM and the stylesheet. */
|
|
11
|
+
export declare const CLASS: {
|
|
12
|
+
readonly button: 'dsh-rewind-btn';
|
|
13
|
+
readonly popover: 'dsh-rewind-popover';
|
|
14
|
+
readonly popoverTitle: 'dsh-rewind-popover-title';
|
|
15
|
+
readonly popoverTarget: 'dsh-rewind-popover-target';
|
|
16
|
+
readonly popoverOption: 'dsh-rewind-popover-option';
|
|
17
|
+
readonly popoverOptionLabel: 'dsh-rewind-popover-option-label';
|
|
18
|
+
readonly popoverOptionHint: 'dsh-rewind-popover-option-hint';
|
|
19
|
+
readonly popoverImpact: 'dsh-rewind-popover-impact';
|
|
20
|
+
readonly popoverActions: 'dsh-rewind-popover-actions';
|
|
21
|
+
readonly popoverPrimary: 'dsh-rewind-popover-primary';
|
|
22
|
+
readonly popoverGhost: 'dsh-rewind-popover-ghost';
|
|
23
|
+
};
|
|
24
|
+
/** The ↶ glyph, drawn inline so the bundle stays dependency-free. */
|
|
25
|
+
export declare const REWIND_ICON_SVG: string;
|
|
26
|
+
/** One injected stylesheet (scoped under `.dsh-rewind-*`). */
|
|
27
|
+
export declare const STYLE = "\n.dsh-rewind-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 6px;\n border: none;\n border-radius: 28px;\n background: transparent;\n color: var(--dsw-alias-label-tertiary);\n cursor: pointer;\n}\n.dsh-rewind-btn:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-secondary);\n}\n\n.dsh-rewind-popover {\n position: fixed;\n z-index: 1000;\n width: 288px;\n padding: 12px;\n border-radius: 12px;\n background: var(--dsw-specific-surface-1, var(--dsw-alias-surface-1, #1f2127));\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.32);\n font-size: 14px;\n line-height: 20px;\n color: var(--dsw-alias-label-primary);\n}\n.dsh-rewind-popover-title {\n font-size: 14px;\n font-weight: 600;\n line-height: 20px;\n}\n.dsh-rewind-popover-target {\n margin: 4px 0 10px;\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n word-break: break-all;\n}\n.dsh-rewind-popover-option {\n display: flex;\n flex-direction: column;\n gap: 2px;\n width: 100%;\n margin: 0 0 6px;\n padding: 8px 10px;\n border: 1px solid transparent;\n border-radius: 8px;\n background: transparent;\n color: inherit;\n font: inherit;\n text-align: left;\n cursor: pointer;\n}\n.dsh-rewind-popover-option:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n.dsh-rewind-popover-option-label {\n font-weight: 500;\n}\n.dsh-rewind-popover-option-hint {\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-tertiary);\n}\n.dsh-rewind-popover-impact {\n margin: 4px 0 10px;\n padding: 8px 10px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n font-size: 12px;\n line-height: 16px;\n color: var(--dsw-alias-label-secondary);\n white-space: pre-wrap;\n max-height: 160px;\n overflow: auto;\n}\n.dsh-rewind-popover-actions {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n}\n.dsh-rewind-popover-primary,\n.dsh-rewind-popover-ghost {\n padding: 5px 12px;\n border: none;\n border-radius: 8px;\n font: inherit;\n font-size: 13px;\n line-height: 18px;\n cursor: pointer;\n}\n.dsh-rewind-popover-primary {\n background: var(--dsw-alias-accent, var(--dsw-accent, #5b8cff));\n color: var(--dsw-alias-on-accent, #fff);\n}\n.dsh-rewind-popover-primary:disabled {\n opacity: 0.5;\n cursor: default;\n}\n.dsh-rewind-popover-ghost {\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n}\n.dsh-rewind-popover-ghost:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-rewind host half: the `/rewind` command and the write-class change
|
|
3
|
+
* ledger, composed as one dual-face bundle row (the browser half lives in
|
|
4
|
+
* `src/client/`).
|
|
5
|
+
*
|
|
6
|
+
* Rewind mechanism: planning is pure (`src/rewind.ts`); execution appends a
|
|
7
|
+
* marker `user/message` into the session log whose `surfaceOp` replaces every
|
|
8
|
+
* surface node after the target message with the marker. The append-only log
|
|
9
|
+
* (and the rendered transcript) is untouched — only the model-visible surface
|
|
10
|
+
* is cut, so the next request derives its context from the target onward.
|
|
11
|
+
* Mode `both` additionally reverses every ledger-recorded file change that
|
|
12
|
+
* followed the target.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-rewind
|
|
15
|
+
*/
|
|
16
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
17
|
+
export declare const name = "dsh-rewind";
|
|
18
|
+
export declare const inject: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Register the `/rewind` command and the tools-pipeline ledger hooks.
|
|
21
|
+
*
|
|
22
|
+
* The command is fs-independent and registers immediately. The ledger needs
|
|
23
|
+
* `fs`, so its hooks mount through a dynamic `ctx.inject(['fs'])` — they take
|
|
24
|
+
* effect whenever the fs service becomes available (and never fail the
|
|
25
|
+
* plugin's load when a deployment has no fs).
|
|
26
|
+
*
|
|
27
|
+
* Capture runs in `tools/execute` (the around-dispatch stage), NOT in
|
|
28
|
+
* `tools/pre-execute`: a pre-execute `{ kind: 'ask' }` short-circuit from
|
|
29
|
+
* another plugin (e.g. dsh-edit-approval) skips later pre-execute listeners,
|
|
30
|
+
* and a denied call never dispatches — so approved calls are still captured,
|
|
31
|
+
* denied calls never leave a pending entry behind.
|
|
32
|
+
*
|
|
33
|
+
* @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function apply(ctx: Context): void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory change ledger: records every write-class tool mutation that
|
|
3
|
+
* happened while the plugin was running, so a "rewind conversation and code"
|
|
4
|
+
* can reverse the changes that followed a target message.
|
|
5
|
+
*
|
|
6
|
+
* Scope (v0.1): the ledger covers only `write` / `edit` / `str_replace_editor`
|
|
7
|
+
* mutations observed through the tools pipeline while the plugin is loaded.
|
|
8
|
+
* Changes made by bash or external programs are not recorded and cannot be
|
|
9
|
+
* restored; a git-first snapshot layer is a v2 option.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-rewind/ledger
|
|
12
|
+
*/
|
|
13
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
14
|
+
/** One recorded write-class mutation. */
|
|
15
|
+
export interface LedgerEntry {
|
|
16
|
+
/** Tool that made the change: `write` | `edit` | `str_replace_editor`. */
|
|
17
|
+
readonly toolName: string;
|
|
18
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
19
|
+
readonly anchorSeq: number;
|
|
20
|
+
/** Display path (model/UI-facing), as resolved at record time. */
|
|
21
|
+
readonly path: string;
|
|
22
|
+
/** Full file content before the change; undefined when the file was created. */
|
|
23
|
+
readonly before: string | undefined;
|
|
24
|
+
/** Full file content after the change. */
|
|
25
|
+
readonly after: string;
|
|
26
|
+
}
|
|
27
|
+
/** Unique per-file impact of rewinding past a target message. */
|
|
28
|
+
export interface FileImpact {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
/** `restore` = the file existed before the target; `delete` = created after it. */
|
|
31
|
+
readonly action: 'restore' | 'delete';
|
|
32
|
+
}
|
|
33
|
+
/** Result of one reverse restore pass. */
|
|
34
|
+
export interface RestoreOutcome {
|
|
35
|
+
readonly restored: readonly string[];
|
|
36
|
+
readonly deleted: readonly string[];
|
|
37
|
+
readonly failed: readonly {
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
/** Deletes one file by its process path (the host supplies the backend-appropriate delete). */
|
|
43
|
+
export type DeleteFile = (processPath: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Per-session cap on recorded entries. The ledger is intentionally bounded so
|
|
46
|
+
* an extremely long session cannot grow one entry list without limit; the
|
|
47
|
+
* oldest entries are dropped first, so rewinds to very early messages in a
|
|
48
|
+
* pathological session may lose the earliest file history (a declared
|
|
49
|
+
* tradeoff, see README).
|
|
50
|
+
*/
|
|
51
|
+
export declare const MAX_LEDGER_ENTRIES = 2000;
|
|
52
|
+
/**
|
|
53
|
+
* Append-only change ledger. Entries are recorded in commit order; a rewind
|
|
54
|
+
* replays them in reverse for the affected range. Bounded per session to
|
|
55
|
+
* {@link MAX_LEDGER_ENTRIES} (oldest dropped first).
|
|
56
|
+
*/
|
|
57
|
+
export declare class RewindLedger {
|
|
58
|
+
private readonly entries;
|
|
59
|
+
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
60
|
+
record(entry: LedgerEntry): void;
|
|
61
|
+
/**
|
|
62
|
+
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
63
|
+
* is inclusive: rewinding to a message also reverts the changes its own
|
|
64
|
+
* turn caused (the rewind cut removes that turn's assistant response and
|
|
65
|
+
* tool calls), so only changes anchored at earlier messages survive.
|
|
66
|
+
*/
|
|
67
|
+
changesAfter(targetSeq: number): readonly LedgerEntry[];
|
|
68
|
+
/**
|
|
69
|
+
* Unique per-file impact for preview. A file whose earliest affected change
|
|
70
|
+
* created it (`before === undefined`) is deleted on restore; any other file
|
|
71
|
+
* is written back to its pre-target content.
|
|
72
|
+
*/
|
|
73
|
+
impactsAfter(targetSeq: number): readonly FileImpact[];
|
|
74
|
+
/**
|
|
75
|
+
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
76
|
+
* its pre-change content back; a file that did not exist before the target
|
|
77
|
+
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
78
|
+
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
79
|
+
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
80
|
+
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
81
|
+
* @param options - session workspace cwd (relative ledger paths resolve
|
|
82
|
+
* against it, mirroring the fs tools) and an optional abort signal.
|
|
83
|
+
*/
|
|
84
|
+
restoreAfter(fs: FileSystem, deleteFile: DeleteFile, targetSeq: number, options?: {
|
|
85
|
+
cwd?: string;
|
|
86
|
+
signal?: AbortSignal;
|
|
87
|
+
}): Promise<RestoreOutcome>;
|
|
88
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rewind planning: target resolution and surface-range computation.
|
|
3
|
+
* No I/O and no `Session` dependency — everything derives from the event log
|
|
4
|
+
* and the ordered surface, so this module stays unit-testable.
|
|
5
|
+
*
|
|
6
|
+
* Rewind semantics (see README): rewinding to a user message appends a marker
|
|
7
|
+
* node into the session log whose `surfaceOp` replaces every surface node
|
|
8
|
+
* AFTER the target with itself. The log (the audit trail and the rendered
|
|
9
|
+
* transcript) is untouched; only the model-visible surface is cut, so the
|
|
10
|
+
* next request derives its context from the target message onward.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-rewind/rewind
|
|
13
|
+
*/
|
|
14
|
+
import type { SessionEvent, UserMessage } from '@deepseek-ai/dsh-session';
|
|
15
|
+
/** Which of the two rewind modes a rewind executes. */
|
|
16
|
+
export type RewindMode = 'chat' | 'both';
|
|
17
|
+
/**
|
|
18
|
+
* Parse-level rewind target. The command line accepts both forms:
|
|
19
|
+
* - `@<seq>` — an absolute log seq (what the UI button always sends);
|
|
20
|
+
* - `<index>` — a 1-based recency index into the listed candidates
|
|
21
|
+
* (1 = most recent user message; the step-by-step command flow uses this).
|
|
22
|
+
*/
|
|
23
|
+
export type RewindTarget = {
|
|
24
|
+
kind: 'seq';
|
|
25
|
+
seq: number;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'index';
|
|
28
|
+
index: number;
|
|
29
|
+
};
|
|
30
|
+
/** Expected failure codes; each maps to a concise human outcome. */
|
|
31
|
+
export type RewindErrorCode = 'no-user-messages' | 'invalid-index' | 'not-a-user-message' | 'not-on-surface';
|
|
32
|
+
/** A typed rewind failure. The host renders `code` into user-facing copy. */
|
|
33
|
+
export declare class RewindError extends Error {
|
|
34
|
+
readonly code: RewindErrorCode;
|
|
35
|
+
constructor(code: RewindErrorCode, message: string);
|
|
36
|
+
}
|
|
37
|
+
/** One selectable rewind candidate: a user message currently on the surface. */
|
|
38
|
+
export interface RewindCandidate {
|
|
39
|
+
/** Absolute log seq of the `user/message` event. */
|
|
40
|
+
readonly seq: number;
|
|
41
|
+
/** Unix epoch ms of the event. */
|
|
42
|
+
readonly time: number;
|
|
43
|
+
/** Truncated plain-text preview of the message content. */
|
|
44
|
+
readonly preview: string;
|
|
45
|
+
/** 1-based recency index in the candidate list (1 = most recent). */
|
|
46
|
+
readonly index: number;
|
|
47
|
+
}
|
|
48
|
+
/** A validated rewind: the target plus the exact surface range to shadow. */
|
|
49
|
+
export interface RewindPlan {
|
|
50
|
+
/** Target user message seq (stays on the surface). */
|
|
51
|
+
readonly targetSeq: number;
|
|
52
|
+
/** The target's ordered position in the surface. */
|
|
53
|
+
readonly targetIndex: number;
|
|
54
|
+
/** Ordered surface node seqs the rewind shadows (everything after the target). */
|
|
55
|
+
readonly shadowedSeqs: readonly number[];
|
|
56
|
+
/** First surface node after the target — the replace range start (inclusive). */
|
|
57
|
+
readonly surfaceStart: number;
|
|
58
|
+
/** Last surface node — the replace range end (inclusive). */
|
|
59
|
+
readonly surfaceEnd: number;
|
|
60
|
+
}
|
|
61
|
+
/** Preview length cap for candidate listings. */
|
|
62
|
+
export declare const CANDIDATE_PREVIEW_CHARS = 80;
|
|
63
|
+
/** Narrow an event to a user message. */
|
|
64
|
+
export declare function isUserMessageEvent(event: SessionEvent): event is SessionEvent<'user/message'>;
|
|
65
|
+
/** Join the text blocks of a message into one plain string. */
|
|
66
|
+
export declare function messagePreview(message: UserMessage): string;
|
|
67
|
+
/**
|
|
68
|
+
* Parse a raw command token into a rewind target.
|
|
69
|
+
* @param raw - one token: `@123` (absolute seq) or `12` (recency index).
|
|
70
|
+
* @returns the parsed target, or undefined when the token is malformed.
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseRewindTarget(raw: string): RewindTarget | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* List the selectable rewind candidates: user messages currently on the
|
|
75
|
+
* surface, most recent first. Shadowed (compacted-away) user messages are
|
|
76
|
+
* intentionally excluded — the rewind boundary cannot be placed where the
|
|
77
|
+
* model context no longer reaches.
|
|
78
|
+
* @param events - the full session event log.
|
|
79
|
+
* @param surface - the ordered surface node seqs (`session.surface.nodes`).
|
|
80
|
+
* @param limit - maximum number of candidates to return.
|
|
81
|
+
* @returns candidates numbered 1..N by recency.
|
|
82
|
+
*/
|
|
83
|
+
export declare function listRewindCandidates(events: readonly SessionEvent[], surface: readonly number[], limit?: number): RewindCandidate[];
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a target against the session log and surface into a validated plan.
|
|
86
|
+
* @param events - the full session event log.
|
|
87
|
+
* @param surface - the ordered surface node seqs.
|
|
88
|
+
* @param target - the parsed target.
|
|
89
|
+
* @returns the validated rewind plan.
|
|
90
|
+
* @throws {RewindError} with a typed code when the target is unusable.
|
|
91
|
+
*/
|
|
92
|
+
export declare function planRewind(events: readonly SessionEvent[], surface: readonly number[], target: RewindTarget): RewindPlan;
|
|
93
|
+
/** Human rendering of a candidate list line (`/rewind` step 1). */
|
|
94
|
+
export declare function formatCandidate(candidate: RewindCandidate): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-cwd resolution for ledger reads/restores, mirroring the fs tools'
|
|
3
|
+
* own rule (`@deepseek-ai/dsh-tool-fs/session-cwd.ts`): relative paths
|
|
4
|
+
* resolve against the calling agent's session workspace
|
|
5
|
+
* (`exec.agent.session.header.cwd`), not the server's launch dir.
|
|
6
|
+
*
|
|
7
|
+
* Pure functions: the only runtime dependency is `canonicalPath`, applied
|
|
8
|
+
* when either the cwd or the requested path contains a parent traversal so a
|
|
9
|
+
* symlinked cwd's filesystem identity stays observable — identical to the
|
|
10
|
+
* tool boundary behavior.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-rewind/session-cwd
|
|
13
|
+
*/
|
|
14
|
+
import type { ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
15
|
+
/**
|
|
16
|
+
* The session workspace cwd to resolve `requestedPath` against, or undefined
|
|
17
|
+
* when no session cwd applies (the filesystem backend then uses its own
|
|
18
|
+
* default base).
|
|
19
|
+
* @param cwd - the session's `header.cwd`, if any.
|
|
20
|
+
* @param requestedPath - the path the provider will resolve.
|
|
21
|
+
* @returns the cwd, canonicalized when traversal could expose a symlink.
|
|
22
|
+
*/
|
|
23
|
+
export declare function sessionCwd(cwd: string | undefined, requestedPath: string): string | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Session cwd for one tool execution (same rule as the fs tools).
|
|
26
|
+
* @param exec - the tool-execution context; only its optional `agent` is read.
|
|
27
|
+
* @param requestedPath - the path the provider will resolve.
|
|
28
|
+
*/
|
|
29
|
+
export declare function execSessionCwd(exec: ToolExecution, requestedPath: string): string | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -22,9 +22,17 @@
|
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "lib/index.js",
|
|
25
|
+
"types": "lib/types/index.d.ts",
|
|
25
26
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
-
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./lib/types/index.d.ts",
|
|
29
|
+
"default": "./lib/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./client": {
|
|
32
|
+
"types": "./lib/types/client/index.d.ts",
|
|
33
|
+
"default": "./lib/client.js"
|
|
34
|
+
},
|
|
35
|
+
"./src/*": "./src/*",
|
|
28
36
|
"./package.json": "./package.json"
|
|
29
37
|
},
|
|
30
38
|
"files": [
|
|
@@ -53,7 +61,6 @@
|
|
|
53
61
|
"verify:host": "node scripts/verify-host.mjs",
|
|
54
62
|
"prepare": "npm run build"
|
|
55
63
|
},
|
|
56
|
-
"license": "MIT",
|
|
57
64
|
"publishConfig": {
|
|
58
65
|
"access": "public"
|
|
59
66
|
},
|
|
@@ -71,17 +78,39 @@
|
|
|
71
78
|
"@deepseek-ai/dsh-tools": "*"
|
|
72
79
|
},
|
|
73
80
|
"peerDependenciesMeta": {
|
|
74
|
-
"@deepseek-ai/cordis": {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"@deepseek-ai/dsh-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"@deepseek-ai/dsh-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"@deepseek-ai/dsh-
|
|
84
|
-
|
|
81
|
+
"@deepseek-ai/cordis": {
|
|
82
|
+
"optional": true
|
|
83
|
+
},
|
|
84
|
+
"@deepseek-ai/dsh-agent": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"@deepseek-ai/dsh-client-locale": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"@deepseek-ai/dsh-client-runtime": {
|
|
91
|
+
"optional": true
|
|
92
|
+
},
|
|
93
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"@deepseek-ai/dsh-commands": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"@deepseek-ai/dsh-fs": {
|
|
100
|
+
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"@deepseek-ai/dsh-llm": {
|
|
103
|
+
"optional": true
|
|
104
|
+
},
|
|
105
|
+
"@deepseek-ai/dsh-sandbox": {
|
|
106
|
+
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"@deepseek-ai/dsh-session": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
111
|
+
"@deepseek-ai/dsh-tools": {
|
|
112
|
+
"optional": true
|
|
113
|
+
}
|
|
85
114
|
},
|
|
86
115
|
"devDependencies": {
|
|
87
116
|
"@deepseek-ai/cordis": "^4.0.1-rc.4",
|
package/scripts/build.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* parse, and the host half must import with the expected plugin shape.
|
|
19
19
|
*/
|
|
20
20
|
import { build } from 'esbuild'
|
|
21
|
+
import { execSync } from 'node:child_process'
|
|
21
22
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
22
23
|
import { dirname, join } from 'node:path'
|
|
23
24
|
import { fileURLToPath } from 'node:url'
|
|
@@ -27,6 +28,10 @@ const pkg = JSON.parse(await readFile(join(ROOT, 'package.json'), 'utf8'))
|
|
|
27
28
|
|
|
28
29
|
await mkdir(join(ROOT, 'lib'), { recursive: true })
|
|
29
30
|
|
|
31
|
+
// ---- type declarations: host (lib/types) + client (lib/types/client) ----
|
|
32
|
+
execSync('npx tsc -p tsconfig.build.json', { cwd: ROOT, stdio: 'inherit' })
|
|
33
|
+
execSync('npx tsc -p tsconfig.client.json', { cwd: ROOT, stdio: 'inherit' })
|
|
34
|
+
|
|
30
35
|
// ---- host half: bundled TS -> ESM (@deepseek-ai/* stays external) ----
|
|
31
36
|
await build({
|
|
32
37
|
entryPoints: [join(ROOT, 'src', 'index.ts')],
|
|
@@ -77,4 +82,8 @@ for (const needle of ['name', 'inject', 'apply']) {
|
|
|
77
82
|
for (const needle of ['window.__ModuleLoader__.load', `id: ${JSON.stringify(pkg.name)}`]) {
|
|
78
83
|
if (!bundle.includes(needle)) throw new Error(`client bundle missing ${needle}`)
|
|
79
84
|
}
|
|
80
|
-
|
|
85
|
+
// Declarations must exist (published tarball carries them).
|
|
86
|
+
for (const dts of ['lib/types/index.d.ts', 'lib/types/client/index.d.ts']) {
|
|
87
|
+
await readFile(join(ROOT, dts), 'utf8')
|
|
88
|
+
}
|
|
89
|
+
console.log('build ok: lib/index.js (host), lib/client.js (client), lib/types/ (declarations)')
|