@kangyishuai/dsh-session-rewind 0.1.0 → 0.1.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.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Unofficial plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). Not affiliated with DeepSeek.
10
10
 
11
- > **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home, 155 unit tests, and a CDP-driven desktop pass (conflict refusal, conversation + file rewind, created-file trash, modified-file restore, rescue restore, panel/audit/diagnostics). Young software — keep backups of anything you cannot lose.
11
+ > **Status: early.** Verified end to end on DSH 0.2.2-rc.1: 31 GUI assertions in a throwaway DSH home, 158 unit tests, and a CDP-driven desktop pass (conflict refusal, conversation + file rewind, created-file trash, modified-file restore, rescue restore, panel/audit/diagnostics). Young software — keep backups of anything you cannot lose.
12
12
 
13
13
  ## What it does
14
14
 
@@ -79,7 +79,7 @@ All plugin data lives under `<DSH_HOME>/session-rewind/` (`DSH_HOME` defaults to
79
79
  | `rescues/<session>/<rescueId>/` | rescue points (current files before a restore) |
80
80
  | `audit.jsonl` | one line per rewind (append-only) |
81
81
 
82
- Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
82
+ On startup the plugin also removes data for sessions that no longer exist (they were deleted); the audit log is kept. Retention: the 20 latest message anchors per session, the 5 latest rescue points, the 20 latest trash operations (30 days), and a 1 GiB soft cap across blobs/trash/rescues. When the cap is exceeded, old trash → old rescues → unreferenced blobs → oldest anchors are cleaned in that order; if still over the cap, new captures stop and diagnostics report it (no referenced data is deleted). Uninstalling keeps the data. The data root can be overridden with the plugin's `dataRoot` config.
83
83
 
84
84
  ## Side effects & risks
85
85
 
@@ -94,7 +94,7 @@ Retention: the 20 latest message anchors per session, the 5 latest rescue points
94
94
 
95
95
  | DSH version | Status |
96
96
  | ----------- | --------------------------------------------------------------------------------------------------------------------------- |
97
- | 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) + 155 unit tests + CDP-driven desktop pass (conversation-only and file modes) |
97
+ | 0.2.2-rc.1 | Verified: `pnpm smoke:session-rewind` (31/31) + 158 unit tests + CDP-driven desktop pass (conversation-only and file modes) |
98
98
 
99
99
  The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is pre-release and semver ranges over prereleases are unreliable (see [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md)). Treat the table as the compatibility statement. After a DSH upgrade run `pnpm probe:runtime`, `pnpm smoke:session-rewind`, and check the plugin's diagnostics tab; if a private seam changed, the plugin degrades instead of breaking the session.
100
100
 
@@ -103,7 +103,8 @@ The plugin declares no `engines.dsh` and no `@deepseek-ai/*` peer range: DSH is
103
103
  ```bash
104
104
  pnpm test # unit tests (host engine, host RPC, client helpers)
105
105
  pnpm verify # repo-wide gates
106
- pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
106
+ pnpm smoke:session-rewind # temp DSH home + real GUI end-to-end (needs dsh & ego-browser)
107
+ pnpm smoke:session-rewind-desktop # read-only desktop check (needs --remote-debugging-port=9222)
107
108
  ```
108
109
 
109
110
  Design notes: [ADR 0006](../../docs/adr/0006-session-rewind-architecture.md) and [ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md) (Chinese; the repository docs are written in Chinese).
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  非官方插件,与 DeepSeek 无关。面向 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness)。
10
10
 
11
- > **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 + 155 个单测 + CDP 驱动的桌面版实测(冲突拒绝、对话+文件回退、新建文件进回收站、修改文件还原、救援点恢复、面板/审计/诊断)。插件还年轻,重要数据请自行备份。
11
+ > **状态:早期。** 已在 DSH 0.2.2-rc.1 上端到端验证:一次性 DSH 目录里 31 项 GUI 断言 + 158 个单测 + CDP 驱动的桌面版实测(冲突拒绝、对话+文件回退、新建文件进回收站、修改文件还原、救援点恢复、面板/审计/诊断)。插件还年轻,重要数据请自行备份。
12
12
 
13
13
  ## 它做什么
14
14
 
@@ -79,7 +79,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
79
79
  | `rescues/<session>/<rescueId>/` | 还原前的救援点 |
80
80
  | `audit.jsonl` | 每次回溯一行(只追加) |
81
81
 
82
- 保留策略:每会话最近 20 个消息锚点、最近 5 份救援点、最近 20 次回收站操作(30 天)、blobs/trash/rescues 合计 1 GiB 软上限。超限时按「旧回收站 → 旧救援点 → 无引用 blob → 最旧锚点」清理;仍超限则停止新捕获并在诊断里告警(不删有引用的数据)。卸载保留数据。数据根可用插件配置 `dataRoot` 覆盖。
82
+ 启动时会自动清理已删除会话的残留数据(entries/trash/救援点);审计日志保留。保留策略:每会话最近 20 个消息锚点、最近 5 份救援点、最近 20 次回收站操作(30 天)、blobs/trash/rescues 合计 1 GiB 软上限。超限时按「旧回收站 → 旧救援点 → 无引用 blob → 最旧锚点」清理;仍超限则停止新捕获并在诊断里告警(不删有引用的数据)。卸载保留数据。数据根可用插件配置 `dataRoot` 覆盖。
83
83
 
84
84
  ## 副作用与风险
85
85
 
@@ -94,7 +94,7 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
94
94
 
95
95
  | DSH 版本 | 状态 |
96
96
  | ---------- | ------------------------------------------------------------------------------------------------- |
97
- | 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 + 155 个单测 + CDP 驱动的桌面版实测(仅对话与文件模式) |
97
+ | 0.2.2-rc.1 | 已验证:`pnpm smoke:session-rewind` 31/31 + 158 个单测 + CDP 驱动的桌面版实测(仅对话与文件模式) |
98
98
 
99
99
  插件不声明 `engines.dsh`,也不声明 `@deepseek-ai/*` 的 peer 范围:DSH 还在预发布阶段,预发布版本的 semver 范围不可靠(见 [ADR 0005](../../docs/adr/0005-dsh-version-compatibility.md))。以这张表为准。DSH 升级后跑 `pnpm probe:runtime`、`pnpm smoke:session-rewind`,并看插件诊断页签;私有 seam 变了会降级而不是让会话出错。
100
100
 
@@ -103,7 +103,8 @@ dsh plugin --profile desktop add /tmp/kangyishuai-dsh-session-rewind-*.tgz
103
103
  ```bash
104
104
  pnpm test # 单测(宿主文件引擎、宿主 RPC、客户端 helper)
105
105
  pnpm verify # 仓库统一门禁
106
- pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
106
+ pnpm smoke:session-rewind # 临时 DSH 目录 + 真实 GUI 端到端(需要 dsh 与 ego-browser)
107
+ pnpm smoke:session-rewind-desktop # 只读桌面回归(需要以 --remote-debugging-port=9222 重启)
107
108
  ```
108
109
 
109
110
  设计记录:[ADR 0006](../../docs/adr/0006-session-rewind-architecture.md)、[ADR 0007](../../docs/adr/0007-session-rewind-file-restore.md)。
package/lib/orphans.js ADDED
@@ -0,0 +1,141 @@
1
+ // @ts-check
2
+ // 孤儿数据清理:会话被删除后,`<DSH_HOME>/session-rewind/` 里可能还留着它的
3
+ // entries / trash / rescues。本模块只做“找出并删除这些残留”,不碰仍然存在的会话,
4
+ // 也不碰 audit.jsonl(审计是 append-only 的历史记录)。
5
+ //
6
+ // 安全性:
7
+ // - 只扫描 `pluginHome` 下的 sessions/ trash/ rescues/ 三个目录;
8
+ // - 会话 id 必须匹配安全字符集,避免路径穿越;
9
+ // - 只有「在 <dataRoot>/sessions/<workspace>/<sessionId> 里找不到」的 id 才算孤儿;
10
+ // - 删除前可以 dryRun 只统计;删除失败逐条记录,不中断其余清理。
11
+ import { readdir, rm, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ /** 目录名安全字符集。 */
15
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
16
+
17
+ /**
18
+ * @typedef {object} OrphanReport
19
+ * @property {string[]} sessionIds 插件 data 里存在、但会话已不存在的 id
20
+ * @property {string[]} trashIds 孤儿 trash 目录对应的 id
21
+ * @property {string[]} rescueIds 孤儿 rescue 目录对应的 id
22
+ * @property {number} bytes 估算占用字节数
23
+ */
24
+
25
+ /**
26
+ * 读取目录下的子目录名;不存在时返回空数组。
27
+ * @param {string} dir
28
+ * @param {any} [fs]
29
+ * @returns {Promise<string[]>}
30
+ */
31
+ async function listDirs(dir, fs = { readdir }) {
32
+ let entries;
33
+ try {
34
+ entries = await fs.readdir(dir, { withFileTypes: true });
35
+ } catch (_error) {
36
+ return [];
37
+ }
38
+ return entries
39
+ .filter((/** @type {any} */ entry) => entry.isDirectory() && SAFE_ID.test(entry.name))
40
+ .map((/** @type {any} */ entry) => entry.name)
41
+ .sort();
42
+ }
43
+
44
+ /**
45
+ * 递归计算目录字节数(估算)。
46
+ * @param {string} target
47
+ * @param {any} [fs]
48
+ * @returns {Promise<number>}
49
+ */
50
+ async function dirBytes(target, fs = { readdir, stat }) {
51
+ let info;
52
+ try {
53
+ info = await fs.stat(target);
54
+ } catch (_error) {
55
+ return 0;
56
+ }
57
+ if (!info.isDirectory()) return info.size;
58
+ let total = 0;
59
+ let entries;
60
+ try {
61
+ entries = await fs.readdir(target);
62
+ } catch (_error) {
63
+ return 0;
64
+ }
65
+ for (const entry of entries)
66
+ total += await dirBytes(join(target, /** @type {string} */ (entry)), fs);
67
+ return total;
68
+ }
69
+
70
+ /**
71
+ * 找出插件数据里已经没有对应会话的 id。
72
+ * @param {{dataRoot: string, pluginHome?: string, fs?: any}} input
73
+ * @returns {Promise<OrphanReport>}
74
+ */
75
+ export async function findOrphans(input) {
76
+ /** @type {any} */
77
+ const fs = input.fs ?? { readdir, rm, stat };
78
+ const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
79
+ const existing = new Set();
80
+ const workspaceRoot = join(input.dataRoot, "sessions");
81
+ for (const workspace of await listDirs(workspaceRoot, fs)) {
82
+ for (const sessionId of await listDirs(join(workspaceRoot, workspace), fs)) {
83
+ existing.add(sessionId);
84
+ }
85
+ }
86
+ const sessionIds = [];
87
+ const trashIds = [];
88
+ const rescueIds = [];
89
+ let bytes = 0;
90
+ for (const id of await listDirs(join(pluginHome, "sessions"), fs)) {
91
+ if (existing.has(id)) continue;
92
+ sessionIds.push(id);
93
+ bytes += await dirBytes(join(pluginHome, "sessions", id), fs);
94
+ }
95
+ for (const id of await listDirs(join(pluginHome, "trash"), fs)) {
96
+ if (existing.has(id)) continue;
97
+ trashIds.push(id);
98
+ bytes += await dirBytes(join(pluginHome, "trash", id), fs);
99
+ }
100
+ for (const id of await listDirs(join(pluginHome, "rescues"), fs)) {
101
+ if (existing.has(id)) continue;
102
+ rescueIds.push(id);
103
+ bytes += await dirBytes(join(pluginHome, "rescues", id), fs);
104
+ }
105
+ return { sessionIds, trashIds, rescueIds, bytes };
106
+ }
107
+
108
+ /**
109
+ * 删除孤儿数据。`dryRun` 只返回报告、不做任何删除。
110
+ * @param {{dataRoot: string, pluginHome?: string, dryRun?: boolean, fs?: any}} input
111
+ * @returns {Promise<{report: OrphanReport, removed: {sessions: string[], trash: string[], rescues: string[]}, failed: Array<{target: string, error: string}>}>}
112
+ */
113
+ export async function pruneOrphans(input) {
114
+ /** @type {any} */
115
+ const fs = input.fs ?? { readdir, rm, stat };
116
+ const pluginHome = input.pluginHome ?? join(input.dataRoot, "session-rewind");
117
+ const report = await findOrphans({ ...input, pluginHome });
118
+ /** @type {{sessions: string[], trash: string[], rescues: string[]}} */
119
+ const removed = { sessions: [], trash: [], rescues: [] };
120
+ /** @type {Array<{target: string, error: string}>} */
121
+ const failed = [];
122
+ if (input.dryRun === true) return { report, removed, failed };
123
+ /** @type {Array<[string, string[], string[]]>} */
124
+ const plan = [
125
+ [join(pluginHome, "sessions"), report.sessionIds, removed.sessions],
126
+ [join(pluginHome, "trash"), report.trashIds, removed.trash],
127
+ [join(pluginHome, "rescues"), report.rescueIds, removed.rescues],
128
+ ];
129
+ for (const [root, ids, sink] of plan) {
130
+ for (const id of ids) {
131
+ const target = join(root, id);
132
+ try {
133
+ await fs.rm(target, { recursive: true, force: true });
134
+ sink.push(id);
135
+ } catch (error) {
136
+ failed.push({ target, error: error instanceof Error ? error.message : String(error) });
137
+ }
138
+ }
139
+ }
140
+ return { report, removed, failed };
141
+ }
package/lib/service.js CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  resolveRewindTarget,
26
26
  sessionEvents,
27
27
  } from "./operations.js";
28
+ import { findOrphans, pruneOrphans } from "./orphans.js";
28
29
  import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
29
30
 
30
31
  /**
@@ -159,6 +160,8 @@ export class SessionRewindService extends TypertRemoteService {
159
160
  this._store = null;
160
161
  this.installFileTracking();
161
162
  this.installCommand();
163
+ // 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
164
+ if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
162
165
  }
163
166
 
164
167
  /** 惰性创建文件引擎:数据根/配置在服务挂载后解析。 @returns {any} */
@@ -186,6 +189,44 @@ export class SessionRewindService extends TypertRemoteService {
186
189
  return this._store;
187
190
  }
188
191
 
192
+ /**
193
+ * 删除已不存在会话的插件数据(entries/trash/rescues)。audit.jsonl 保留。
194
+ * @returns {Promise<void>}
195
+ */
196
+ async cleanupOrphans() {
197
+ try {
198
+ const result = await pruneOrphans({ dataRoot: this.dataRoot, dryRun: false });
199
+ const removed =
200
+ result.removed.sessions.length +
201
+ result.removed.trash.length +
202
+ result.removed.rescues.length;
203
+ if (removed > 0) {
204
+ this.ctx?.logger?.info?.(
205
+ `session-rewind: cleaned ${removed} orphan data dir(s) (${result.report.bytes} bytes)`,
206
+ );
207
+ try {
208
+ await this.store.appendAudit({
209
+ id: `cleanup-${Date.now()}`,
210
+ action: "cleanup-orphans",
211
+ time: Date.now(),
212
+ sessionId: null,
213
+ sessions: result.removed.sessions,
214
+ trash: result.removed.trash,
215
+ rescues: result.removed.rescues,
216
+ bytes: result.report.bytes,
217
+ failed: result.failed,
218
+ });
219
+ } catch (_error) {
220
+ /* 审计失败不影响清理 */
221
+ }
222
+ }
223
+ } catch (error) {
224
+ this.ctx?.logger?.warn?.(
225
+ `session-rewind: orphan cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
226
+ );
227
+ }
228
+ }
229
+
189
230
  /** 文件追踪是否开启。 @returns {boolean} */
190
231
  trackingEnabled() {
191
232
  return this.config.fileTracking !== false;
@@ -766,6 +807,17 @@ export class SessionRewindService extends TypertRemoteService {
766
807
  } catch (error) {
767
808
  push("审计日志", false, error instanceof Error ? error.message : String(error));
768
809
  }
810
+ try {
811
+ const orphans = await findOrphans({ dataRoot: this.dataRoot });
812
+ const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
813
+ push(
814
+ "孤儿数据",
815
+ count === 0,
816
+ count === 0 ? "none" : `${count} dir(s), ${orphans.bytes} bytes`,
817
+ );
818
+ } catch (error) {
819
+ push("孤儿数据", false, error instanceof Error ? error.message : String(error));
820
+ }
769
821
  if (typeof request?.sessionId === "string" && request.sessionId !== "") {
770
822
  try {
771
823
  const { events, session } = sessionEvents(this.deps(), request.sessionId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kangyishuai/dsh-session-rewind",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "In-place conversation rewind for DSH: withdraw a user message and everything after it, then refill the composer",
5
5
  "keywords": [
6
6
  "dsh",