dsh-conversation-recycle-bin 0.3.22 → 0.3.24

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/AUDIT-INDEX.md ADDED
@@ -0,0 +1,59 @@
1
+ # AUDIT 索引(历史快照 ≠ 当前状态)
2
+
3
+ `AUDIT-*.md` 是**某一时点**的审查快照,写于对应日期所装的 dsh 版本与插件版本之上。
4
+ 它们记录的是"当时发现了什么、当时修了什么",**不能用来判断当前代码是否还有该问题**。
5
+ 把历史 AUDIT 当检查清单逐条核对,最典型的后果是把已经修好的项当成未修项(例如
6
+ `r2.log` 里那条 `state: adapter diagnostics are redacted before they cross HTTP` 当时为失败,
7
+ 现在已通过)。
8
+
9
+ ## 当前状态的唯一判定顺序
10
+
11
+ ```bash
12
+ npm run check # 语法 + manifest
13
+ npm test # 全部套件(失败不中断,见 scripts/run-tests.mjs)
14
+ npm run check:host-api # 静态契约:本机所装 dsh 是否仍与插件的用法一致
15
+ npm run check:host-runtime # 运行态:插件是否真的挂载并应答(需要会话,见下)
16
+ ```
17
+
18
+ 前三条都不需要运行中的 Host。第四条需要一个真实 Host:
19
+
20
+ ```bash
21
+ node scripts/verify-host-contract.mjs --launch=<桌面端 Host 启动 URL>
22
+ node scripts/verify-host-contract.mjs --cookie="dsh-auth-…=…"
23
+ node scripts/verify-host-contract.mjs --cookie-file=<导出的 cookie 行文件>
24
+ node scripts/verify-host-contract.mjs --token=<`dsh web` URL 里的 token>
25
+ ```
26
+
27
+ 桌面端不发 token:它用启动 URL 换一个 authority 绑定的 cookie(`authenticateWebHost`
28
+ 在 Electron 主进程内完成),所以 `--launch` 是桌面载体上唯一可用的自检入口。
29
+
30
+ ## 快照清单(全部为归档)
31
+
32
+ | 文件 | 日期 | 覆盖 | 状态 |
33
+ | --- | --- | --- | --- |
34
+ | `AUDIT-2026-10-17-desktop-web.md` | 2026-10-17 | 桌面端 / 网页端首轮 | 归档 |
35
+ | `AUDIT-2026-10-19-desktop-web-recheck.md` | 2026-10-19 | 上轮复检 | 归档 |
36
+ | `AUDIT-2026-10-19-review.md` | 2026-10-19 | 代码评审 | 归档 |
37
+ | `AUDIT-2026-10-20-last3-versions.md` | 2026-10-20 | 近三个 dsh 版本 | 归档 |
38
+ | `AUDIT-2026-10-21-desktop-web.md` | 2026-10-21 | 桌面端 / 网页端 | 归档 |
39
+ | `AUDIT-2026-10-21-optimization.md` | 2026-10-21 | 性能与结构 | 归档 |
40
+ | `AUDIT-2026-10-22-desktop-web-compat.md` | 2026-10-22 | 兼容性 | 归档 |
41
+ | `AUDIT-2026-10-24-desktop-web-rc3.md` | 2026-10-24 | 桌面端 / 网页端 | 归档 |
42
+ | `AUDIT-2026-10-29-desktop-web.md` | 2026-10-29 | 桌面端 / 网页端 | 归档 |
43
+
44
+ ## 2026-11-14 这一轮的口径
45
+
46
+ 那一轮审查跑在 `@deepseek-ai/dsh-desktop 0.2.2-rc.3`(桌面端,内含
47
+ `@deepseek-ai/dsh-desktop-runtime 0.2.2-rc.3`)与插件 `v0.3.22` 上,结论内联在当次会话里,
48
+ 没有另开 AUDIT 文件。它的可复跑部分已经固化进上面四条命令:
49
+
50
+ - `npm run check:host-api` 承担原来靠人肉比对的契约核对(路由 kind、handler 签名、
51
+ `__ModuleLoader__`、`<id>/client.js`、四个 Slot、engines 范围)。
52
+ - `test/api.test.mjs` 的 `desktop shell:` 用例承担桌面转发器的请求头集合
53
+ (`host`/`origin`/`cookie`/`sec-fetch-site` 被删后重新注入 cookie)这一层。
54
+ - `test/client-module.test.mjs` 的 `require` 守卫承担"客户端半体不依赖全局 require",
55
+ 针对的是 `dsh-desktop 0.2.2-rc.1` 期间 renderer 报过的那条
56
+ `Uncaught ReferenceError: require is not defined`(当时未能归因到本插件)。
57
+
58
+ 以后再有新一轮审查,**新增一份 AUDIT 文件并在上表追加一行**,同时把其中可复跑的断言
59
+ 移进上述脚本——文件是记录,脚本才是防线。
package/CHANGELOG.md CHANGED
@@ -3,6 +3,67 @@
3
3
  All notable changes to `dsh-conversation-recycle-bin` are documented here.
4
4
  The format follows Keep a Changelog; the project uses Semantic Versioning.
5
5
 
6
+ ## [0.3.24] — 2026-11-14
7
+
8
+ 新功能:**归档 → 回收站**。被归档的会话自动登记进回收站,再由既有保留期清扫在 `trashRetentionDays`(默认 30)天后彻底删除,**关联记忆永不被这条路径触碰**。设置页可关闭,默认在升级后一次性询问。
9
+
10
+ 实现前先做了一轮三方评审(架构 / 安全 / 验收),结论里最重的一条直接决定了实现形状:归档事件是在 `workspaceRegistry.archiveSession()` 的 `enqueueOperation` 内部被 `await ctx.parallel(...)` 派发的,所以监听器里**任何** await 的 registry 写都会让该队列等自己 → registry 永久自锁。因此监听器只同步 `schedule()`,真正的工作在 `setImmediate` 里走既有的 `serialize()` 跨进程锁。
11
+
12
+ ### Added
13
+
14
+ - **`lib/core/auto-archive.js`(纯函数)**:三态策略。`unasked | on | off`,生效值 `config.enabled && autoArchive.enabled && state==='on'`。`unasked` 永不执行——「默认开启」只体现为提示里预选「开启」,不替用户启动不可逆计时。另含幂等判定 `findActiveEntryForSession`(`pending/trashed/restoring/purging` 占用,`failed`/`restored` 不占用)与 `shouldPromptForDecision`(只问一次)。
15
+ - **`lib/host/auto-archive-store.js`**:`<dataDir>/auto-archive.json` 原子写(tmp + rename),损坏/未知 schema 一律回落 `unasked`。配置在激活期只读且没有写配置路由,所以设置页的选择必须有自己的落盘位置。
16
+ - **`lib/host/archive-hook.js`**:订阅 `workspace/session-archived` / `workspace/session-unarchiving`(官方既有范式,`dsh-experimental-worktree` 用同一对)。两个 handler 同步、全包 try/catch——`session-unarchiving` 的 rejection 会让宿主**保留归档态**,即用户再也无法取消归档。
17
+ - **入口约束**:`performSessionDelete` 新增 `origin`。`auto-archive` 来源强制 `mode:'soft'`、`includeMemory:false`,并且**传 `includeMemory:true` 直接抛 `E_MEMORY_GUARD`**(而不是静默忽略),否则"不删记忆"会变成调用方自觉。
18
+ - **`POST /archive-auto/ack`**:记录一次性回答。`enabled:false` 时与 `/feedback` 一样仍可达(否则问题永远答不了),但它只写状态文件。
19
+ - **`/state.autoArchive`**:`configured / state / effective / reason / promptPending / hookAvailable / baseArchivedAt / retentionDays`。`hookAvailable:false` 时设置页明说"当前宿主不提供归档事件",不假装承诺 30 天清理。
20
+ - **`scripts/verify-host-api.mjs`**:新增两条断言,`dsh-workspace` 必须仍发布这两个事件。`ctx.on` 对未声明事件是**静默接受**的,所以宿主改名不会抛错,只会让功能永不触发而 UI 继续承诺清理。
21
+
22
+ ### Fixed
23
+
24
+ - **归档来源条目的「恢复」等于没恢复**(`lib/host/catalog.js`):该类条目的快照是 `archived:true`,`restore` 的两个分支都不走,结果是"恢复成功 + 条目消失 + 会话仍隐藏"。`restore(snapshot, {unarchive})` 现在对该来源强制取消归档。
25
+ - **soft 删除对已归档会话的重复 `archiveSession` 调用**(`lib/host/catalog.js`):宿主对已归档 id 会提前返回,但**仍然入队**。自动入站路径正是从归档事件里触发的,这个嵌套入队会死锁。现在已归档就跳过(行为与宿主一致:`stopActivity` 本来也不会执行)。
26
+ - **归档条目在保留期内被取消归档后,倒计时撒谎**(`lib/host/api.js`):清扫会永久 `E_SESSION_ALIVE`,界面却一直显示"还剩 N 天"。`/trash` 现在对自动来源的条目复查一次归档态,取消归档后 `revived:true`、`daysLeft/expiresAt` 为 `null`。
27
+ - **30 天判定完全依赖墙钟**(`lib/host/api.js`):同进程内两次清扫间隔 > 24h 视为时钟跳变(NTP 纠偏、快照恢复、手改时间),本轮只推进高水位、不销毁,下一轮恢复。首次启动不设护栏——机器确实没运行,条目确实更旧。
28
+ - **自动路径绕过归属策略**(`lib/host/api.js`):归属只在 HTTP 路由执行,自动路径不经过路由。现在逐会话跑 `evaluateSessionPolicy`,拒绝时写 `auto-archive-skip` 审计。
29
+ - **审计把无人值守行为记成用户操作**:自动路径现在 `actor:"system:archive-auto"`、`source:"auto-archive"`、`title:null`,事后可区分"用户点删除"与"归档自动登记"。
30
+
31
+ ### Changed
32
+
33
+ - 新增配置段 `autoArchive: { enabled: true, askOnUpgrade: true }`(`config.schema.json` / `config.js` / `cordis.patch.yml` / README §4.4 四处同步;`manifest.test.mjs` 的 schema ⇄ DEFAULTS 断言是闸门)。`enabled` 是运营方否决位,也是提示的预选答案。
34
+ - 回收站 manifest 新增 `origin`(`"user"` / `"auto-archive"`)与 `archivedAt`。旧 manifest 无 `origin`,按用户删除处理。
35
+ - 设置页新增「会话被归档时」卡片:一次性升级询问 + 后续开关 + 当前生效状态;回收站行对自动条目显示「由归档自动登记」「已取消归档——不会自动删除」。
36
+ - 测试:新增 `test/auto-archive.test.mjs`(9 例)与 `api.test.mjs` 的 15 例集成用例;夹具补 `ctx.on` + `registerArchiveHook` 接线(否则写出来的"事件已覆盖"是假通过)。10 个文件 / 233 例 → **11 个文件 / 258 例**,全绿。
37
+
38
+ ### Known limitations
39
+
40
+ - `trashRetentionDays` 无上限截断:单轮最多清 20 条、间隔 6h,所以积压多时实际销毁是 `30 天 + ⌈n/20⌉×6h`,文案写"约 30 天后"而不是"第 30 天准时消失"。
41
+ - 关闭自动开关只挡**新增**,已在册的自动条目仍按 30 天走完(否则"关开关"= 数据永驻);设置页会明示这一点。
42
+ - 归档事件在 `engines.dsh` 下限 `0.2.1-rc.2` 是否存在未验证;本机 `0.2.2-rc.3` 已核实,`check:host-api` 会把这条变成上线前的硬断言。
43
+
44
+ ## [0.3.23] — 2026-11-14
45
+
46
+ 对**桌面端 `@deepseek-ai/dsh-desktop 0.2.2-rc.3`(内含 `dsh-desktop-runtime 0.2.2-rc.3`)+ 插件 `v0.3.22`** 的实测复查之后的修复。结论是「宿主契约没有硬断裂」:全部套件 10/10 通过(`api.test.mjs` 75/75)、语法全绿,`prefix` 路由、`handler(req,res)`、`__ModuleLoader__.load`、`<id>/client.js`、四个 Slot、23 个配置键与 `0.2.2-rc.3` 逐项对得上。本版闭合的是**验证链条**上的四个缺口——它们不会让功能当场坏掉,但会让下一次宿上升级只能靠肉眼判断。
47
+
48
+ ### Fixed
49
+
50
+ - **测试夹具把 `req` 当裸 `EventEmitter`,让「读 body 前有 `await`」的路由永久挂起**(`test/helpers.mjs`):真实 `IncomingMessage` 是 **paused stream**,没人监听时数据留在缓冲里;裸 `EventEmitter` 直接丢弃。于是 `requireOwnerPeer: true` 下的 POST(闸门里 `await connection.admit(req)` 先于读 body)在夹具里死等 `end`,而真实 Host 完全正常——这条差异会让**任何**给路由前置一个 `await` 的改动看起来像死锁。现在已发生的事件会向晚注册的监听器重放,恢复 paused-stream 语义。
51
+ - **`verify-host-contract.mjs` 在桌面载体上恒为 401**(`scripts/verify-host-contract.mjs`):它只认 `--token`,而桌面壳根本不发 token —— `authenticateWebHost()` 在 Electron 主进程内用启动 URL 换一个 authority 绑定的 cookie 并注入转发请求。结果就是「升级后插件是否真的挂载」这条唯一关键检查,在桌面端从来跑不出结论。现在支持 `--launch=<启动URL>`(取 303 的 `set-cookie`)、`--cookie=…`、`--cookie-file=…`,以及对应的 `DSH_LAUNCH_URL` / `DSH_COOKIE` / `DSH_COOKIE_FILE`;401 时的提示也改成四条可选入口,不再只报「没 token」。
52
+
53
+ ### Added
54
+
55
+ - **`scripts/verify-host-api.mjs` + `npm run check:host-api`**:不联网、不需要会话的**静态契约快照**。直接读本机所装的宿主(Windows 桌面自动从 `app.asar` 读,其次 `$DSH_HOST_DIR`,再次 profile 的 `node_modules`),断言六件事:`engines.dsh` 是否放行该宿主版本、`webServer` 仍支持 `kind:'prefix'`、`handler` 仍是 `(req,res)`、`__ModuleLoader__.load` 仍在、客户端资源仍是 `<id>/client.js`、四个 Slot 仍由宿主包贡献,以及 `client.js` 的注册 id 是否等于包名(不等会直接中断 web boot)。宿主升级后先跑它,比人肉比对 `AUDIT-*.md` 可靠。
56
+ - **`AUDIT-INDEX.md`**:说明 `AUDIT-*.md` 全是历史快照、不能当检查清单用;给出当前状态的判定顺序(`check` → `test` → `check:host-api` → `check:host-runtime`),并把 9 份快照列成归档表。`r2.log` 里那条既有的 `state: adapter diagnostics …` 失败在本轮重跑已通过,需要这样的索引才不会被误当成未修项。
57
+ - **`npm run check:host-runtime`**:`verify-host-contract.mjs` 的显式入口(此前只能手敲路径)。
58
+
59
+ ### Tests
60
+
61
+ 新增 3 条用例,并把一条既有行为固定下来:
62
+
63
+ - `api.test.mjs` — `desktop shell: the forwarder header set (no Origin) still reaches the route`:按 `forwardWebRequest()` 的**真实头集合**发请求(`host`/`origin`/`cookie`/`sec-fetch-site` 被删、`Host` 由 target 重设、注入 `dsh-auth-*`),断言三件事同时成立:无 `Origin` 时放行、`requireOwnerPeer` 仍能把非 owner 挡在 403 `E_FORBIDDEN_PRINCIPAL`、缺插件私有头仍是 403。此前只测过「`Origin` 存在且为 `dsh-app://app`」,转发器删头这一路径没有任何覆盖。
64
+ - `client-module.test.mjs` — `the bundle only uses \`require\` through the factory argument`:`dsh-desktop 0.2.2-rc.1` 期间 renderer 报过 `Uncaught ReferenceError: require is not defined`(combo bundle 里含本插件,当时未能归因)。判定不了就让它不可能发生:`require(` 出现在 factory 之外、或出现 `process.env` / `require('node:…')` 即失败,两类载体(浏览器与 `dsh-app://app`)都没有这些全局。
65
+ - `client-module.test.mjs` — `the registered module id equals the package name the boot graph keys on`:id 与包名不一致会被宿主机报成 "loaded without registering" 再二次执行,最终中断整个 web boot。
66
+
6
67
  ## [0.3.22] — 2026-11-12
7
68
 
8
69
  对**当前最新宿���**(`@deepseek-ai/dsh-desktop-runtime` **0.2.2-rc.1**,桌面端与 `dsh web` 共用)的三方只读评审(架构 / 安全 / 验收)之后的修复。评审结论是「宿主契约没有硬断裂,但有一处安装门禁错误、一处标题优先级倒置、一处脱敏缺口」;本版把这四项 P0 与两项 P1 全部闭合,并给出一条可复核的运行态探针。
package/README.md CHANGED
@@ -105,7 +105,7 @@ memory → messages → session/catalog
105
105
  - 队列与锁都有预算,不会无限等待:排队超过 `lockTimeoutMs + 60s` 抛 `E_QUEUE_TIMEOUT`(503,**未执行任何动作**,可安全重试);客户端在排队或取锁期间断开(关标签页/刷新)抛 `E_ABANDONED`(409,同样未执行)。两者都表示"什么都没发生",与 `E_TRANSACTION_FAILED`(可能已部分执行)区分开;
106
106
  - 锁文件 stale 判定:pid 可证明已死才立即破锁;无 owner 的锁只按 age 破;**持有者活着时绝不按 age 破**(否则长 GC 会放进第二个写者)。连续 3 次删不掉 stale 锁即 `E_LOCK_BUSY`,不空转;
107
107
  - 返回 `{ok: true, partial, results, failures}`:`ok` 表示计划已执行,`partial` 表示有会话失败;只有**全部**失败时才抛首个错误(HTTP 状态来自 `ERROR_STATUS`,错误码保留真实策略码)。
108
- 3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging);每行带 `capabilities`,其 `canRestore`/`canDiscard` 同时看 manifest 与磁盘载荷)、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志或记忆文件的条目应走「恢复」而不是 discard)。未完成条目若还留着会话日志或记忆包,设置页会按 `capabilities.canRestore` 给出「恢复」按钮,可以直接把它恢复回会话列表;hard 删除在「日志已移入回收站、manifest 尚未记录」的崩溃窗口里,restore 会从日志头部的 `cwd` 重建原路径并把日志移回,不再出现三路全拒的死条目。
108
+ 3. `GET /trash`(返回 `entries`(可恢复)与 `incomplete`(failed/pending/restoring/purging);每行带 `capabilities`,其 `canRestore`/`canDiscard` 同时看 manifest 与磁盘载荷;`origin:"auto-archive"` 的行额外带 `revived`,取消归档后 `daysLeft`/`expiresAt` 为 `null`,不再显示一个不会兑现的倒计时)、`POST /trash/restore`、`POST /trash/purge`(必须输入 `DELETE`)、`POST /trash/discard`(清理未完成条目,必须输入 `DELETE`;仍保存会话日志或记忆文件的条目应走「恢复」而不是 discard)、`POST /archive-auto/ack`(记录「归档是否自动进回收站」的一次性回答,不删除任何东西;`enabled:false` 时它与 `/feedback` 一样仍可达)。未完成条目若还留着会话日志或记忆包,设置页会按 `capabilities.canRestore` 给出「恢复」按钮,可以直接把它恢复回会话列表;hard 删除在「日志已移入回收站、manifest 尚未记录」的崩溃窗口里,restore 会从日志头部的 `cwd` 重建原路径并把日志移回,不再出现三路全拒的死条目。
109
109
  4. `GET /audit?limit=N`。
110
110
 
111
111
  ### 4.2 安全清单
@@ -158,7 +158,7 @@ memory → messages → session/catalog
158
158
 
159
159
  ### 4.4 配置项全表
160
160
 
161
- `cordis.patch.yml` 的 `config` 段共 22 个键;全部可省略,省略即取默认值。`normalizeConfig` 从不抛错:越界值被钳制、类型错误回落默认,并把每一条修正写进 `/state` 的 `warnings`(`enabled` 例外,它 fail-closed)。
161
+ `cordis.patch.yml` 的 `config` 段共 23 个键;全部可省略,省略即取默认值。`normalizeConfig` 从不抛错:越界值被钳制、类型错误回落默认,并把每一条修正写进 `/state` 的 `warnings`(`enabled` 例外,它 fail-closed)。
162
162
 
163
163
  | 键 | 默认 | 说明 |
164
164
  |---|---|---|
@@ -184,10 +184,30 @@ memory → messages → session/catalog
184
184
  | `auditMaxBytes` | `5242880`(5 MiB) | 审计文件字节上限,超出后压缩(64KB–1GB)。 |
185
185
  | `memory` | `enabled:true`、`adapters:["lingshu","generic-json","none"]`、`deleteMode:"trash"` | 记忆适配器段:`enabled`、`adapters`、`lessons`(灵枢)等。 |
186
186
  | `ui` | `showRowAction/showHeaderAction/batchDoubleConfirm` 均 `true` | 前端座位开关与批量二次确认。 |
187
+ | `autoArchive` | `enabled:true`、`askOnUpgrade:true` | 归档 → 回收站。见 §4.5:`enabled` 既是运营方否决位,也是升级提示的预选答案;**是否真正执行取决于用户在设置页的回答**。 |
187
188
  | `feedback` | 见 §9.2 | 反馈邮件段(非机密)。 |
188
189
 
189
190
  `manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
190
191
 
192
+ ### 4.5 归档 → 回收站(`autoArchive`)
193
+
194
+ 把一个被**归档**的会话自动登记进回收站,再由上面的保留期清扫在 `trashRetentionDays` 天后彻底删除。**关联记忆永不被这条路径触碰**(入口硬编码 `includeMemory:false`,且 `performPurge` 对该来源的条目有 `E_MEMORY_GUARD` 兜底)。
195
+
196
+ | 事实 | 说明 |
197
+ |---|---|
198
+ | 触发 | 宿主 `workspaceRegistry` 的 `workspace/session-archived`(持久写之后派发,payload 仅 `{sessionId}`)。这是官方既有范式:`dsh-experimental-worktree` 用同一对事件做「归档→排定清理、取消归档→撤回」。 |
199
+ | 撤回 | `workspace/session-unarchiving`(在移除 id **之前**、被 await 的取消前钩子)。用户取消归档 → 已排定但未执行的登记被取消。 |
200
+ | 监听器必须同步 | 归档事件是在 `archiveSession()` 的 `enqueueOperation` 内部被 await 的;监听器里任何 `await` 的 registry 写(`await performSessionDelete()` 就是,因为 soft 路径会碰 catalog)都会让这个队列等自己 → **registry 永久自锁**。所以监听器只 `schedule()` 并 `return void`,真正的工作在 `setImmediate` 里、走同一个 `serialize()` 跨进程锁。 |
201
+ | 条目来源 | manifest 新增 `origin:"auto-archive"` 与 `archivedAt`。旧 manifest 没有 `origin`,按用户删除处理(兼容)。 |
202
+ | 幂等 | 同一 `sessionId` 在 `pending/trashed/restoring/purging` 中最多一条;判定用既有 `trash.scanAll()`,不新增索引文件。`failed` 不算占用(允许重试),`pending` 复用(允许崩溃后收尾)。 |
203
+ | 归属策略 | 自动路径**同样**逐会话跑 `evaluateSessionPolicy`:`protectedSessionIds`、`allowedWorkspaceRoots`、子代理/孤儿会话一律不登记,并以 `action:"auto-archive-skip"` 写入审计。 |
204
+ | 审计归因 | 自动路径记 `actor:"system:archive-auto"`、`source:"auto-archive"`,并且**不写标题**(`title:null`),与 purge 的既有口径一致。 |
205
+ | 时钟护栏 | 同一进程内两次清扫间隔 > 24h 视为墙钟跳变(NTP 纠偏、快照恢复、手改时间):本轮只推进高水位、不销毁,下一轮(6h 后)恢复正常。首次启动不设护栏——机器确实没运行,条目确实更旧。 |
206
+ | 恢复语义 | 由归档产生的条目,快照里 `archived:true`;「恢复」对该来源强制**取消归档**,否则会「提示恢复成功、会话仍隐藏」。 |
207
+ | 开关 | 三态 `unasked / on / off`,存 `<dataDir>/auto-archive.json`(不是配置:配置在激活期只读,没有写配置路由)。**`unasked` 永不执行**——「默认开启」表现为升级提示里预选「开启」,而不是替用户启动不可逆计时。关闭只挡**新增**,已在册条目仍按 30 天走完(否则「关开关」= 数据永驻)。 |
208
+ | 提示 | 设置页一次性询问,去重状态在宿主侧(清浏览器缓存不会重复弹、也不能被伪造跳过)。`enabled:false` 时该 ack 路由仍可达(否则问题永远答不了),但它只写状态文件、不删任何东西。 |
209
+ | 何时不可用 | 宿主没有事件总线 / 不发布该事件时:`/state.autoArchive.hookAvailable=false`,设置页明说「无法运行」,不会假装承诺 30 天清理。`npm run check:host-api` 也断言这两个事件仍然存在。 |
210
+
191
211
  ---
192
212
 
193
213
  ## 5. 记忆适配器与诚实边界
@@ -231,6 +251,8 @@ dsh-conversation-recycle-bin/
231
251
  │ │ ├─ transaction.js # 逆序回滚原语
232
252
  │ │ ├─ cascade.js # 三阶段级联 + 恢复 + 彻底删除
233
253
  │ │ ├─ trash.js # 回收站 manifest
254
+ │ │ ├─ retention.js # 保留期谓词(纯函数)
255
+ │ │ ├─ auto-archive.js # 归档→回收站的三态策略与幂等谓词(纯函数)
234
256
  │ │ ├─ audit.js # JSONL 审计 + 压缩
235
257
  │ │ └─ memory/ # 记忆适配器(lingshu / generic-json / none)
236
258
  │ └─ host/ # DSH 服务适配
@@ -240,8 +262,10 @@ dsh-conversation-recycle-bin/
240
262
  │ ├─ lifecycle.js # live 会话停机:cancel + archive + drain
241
263
  │ ├─ memory.js # 适配器装配
242
264
  │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
265
+ │ ├─ auto-archive-store.js # <dataDir>/auto-archive.json(三态状态,原子写)
266
+ │ ├─ archive-hook.js # 订阅 workspace/session-archived|unarchiving(同步、不外抛)
243
267
  │ └─ api.js # 同源 HTTP API、确认 token、限流
244
- └─ test/ # 221 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
268
+ └─ test/ # 258 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + 归档自动入站 + Client 半渲染冒烟
245
269
  ```
246
270
 
247
271
  ---
@@ -250,11 +274,14 @@ dsh-conversation-recycle-bin/
250
274
 
251
275
  ```powershell
252
276
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
253
- npm test # 依次跑 test/*.test.mjs(10 个文件、221 个用例);任一失败仍会跑完全部再汇总
254
- npm run check # 语法 + manifest
255
- node scripts/verify-host-contract.mjs http://127.0.0.1:3082 --token=<token> # 宿主已启动时的运行时探针(boot 图 + /api/state)
277
+ npm test # 依次跑 test/*.test.mjs(11 个文件、258 个用例);任一失败仍会跑完全部再汇总
278
+ npm run check # 语法 + manifest
279
+ npm run check:host-api # 静态契约:本机所装的 dsh 是否仍与插件的用法一致(含归档事件是否仍被发布;不需要 Host 在跑,也不需要会话)
280
+ npm run check:host-runtime -- --launch=<桌面端 Host 启动 URL> # 运行态:插件是否真的挂载并应答
256
281
  ```
257
282
 
283
+ `check:host-runtime` 需要一个已启动的 Host 和它的会话凭据。桌面端**不发 token**:它在 Electron 主进程里用启动 URL 换一个 authority 绑定的 cookie,所以桌面载体上要用 `--launch`;`dsh web` 用 `--token`;从浏览器网络面板复制则可直接用 `--cookie="dsh-auth-…=…"` 或 `--cookie-file=<文件>`。四条入口见 `AUDIT-INDEX.md`。
284
+
258
285
  | 用户要求场景 | 用例 |
259
286
  |---|---|
260
287
  | 单条删除 | `api.test.mjs`「soft delete…」「hard delete…」;`cascade.test.mjs` 顺序/回滚 |
package/client.js CHANGED
@@ -142,6 +142,27 @@ window.__ModuleLoader__.load({
142
142
  'trash.status.purging': 'purging',
143
143
  'trash.status.restored': 'restored',
144
144
  'trash.status.unreadable': 'unreadable manifest',
145
+ // Archive → recycle bin.
146
+ 'auto.title': 'When a conversation is archived',
147
+ 'auto.on': 'Move it to the recycle bin automatically',
148
+ 'auto.off': 'Do nothing — archiving only hides it',
149
+ 'auto.explain': 'An archived conversation is moved into this recycle bin, and the Host permanently deletes it {days} days later. Associated memory is never deleted. Archive it is the only trigger: nothing happens while this is off, and un-archiving a row takes it back out of the countdown.',
150
+ 'auto.enabledOn': 'On — archived conversations enter the recycle bin.',
151
+ 'auto.enabledOff': 'Off — archiving only hides the conversation.',
152
+ 'auto.unasked': 'Not decided yet — nothing is moved.',
153
+ 'auto.disabledByOperator': 'Disabled by the plugin configuration (autoArchive.enabled: false).',
154
+ 'auto.noHook': 'This Host does not publish archive events, so the automatic path cannot run here.',
155
+ 'auto.turnOn': 'Turn on',
156
+ 'auto.turnOff': 'Turn off',
157
+ 'auto.promptTitle': 'New: archived conversations can be swept automatically',
158
+ 'auto.promptBody': 'From this version, archiving a conversation can move it into the recycle bin, where the Host permanently deletes it {days} days later. Conversation memory is never deleted. You can change this at any time below.',
159
+ 'auto.promptAccept': 'Yes, use the recycle bin',
160
+ 'auto.promptDecline': 'No, keep archiving as-is',
161
+ 'auto.savedOn': 'Saved: archived conversations now enter the recycle bin.',
162
+ 'auto.savedOff': 'Saved: archiving no longer moves anything.',
163
+ 'auto.originAuto': 'filed by archiving',
164
+ 'auto.revived': 'un-archived — no longer auto-deleted',
165
+ 'auto.baseSince': 'Active since {time}.',
145
166
  'batch.title': 'Batch deletion',
146
167
  'batch.hint': 'Each conversation is its own transaction; a failure rolls only that conversation back.',
147
168
  'batch.selected': '{count} selected',
@@ -214,6 +235,7 @@ window.__ModuleLoader__.load({
214
235
  'error.E_DISABLED': 'Conversation deletion is disabled by configuration.',
215
236
  'error.E_TRANSACTION_FAILED': 'Deletion failed and was rolled back.',
216
237
  'error.E_MEMORY_FAILED': 'The memory store could not be updated; nothing was deleted.',
238
+ 'error.E_MEMORY_GUARD': 'This entry came from archiving but holds conversation memory, so it was not deleted. Restore it and delete the conversation explicitly if you really want that memory gone.',
217
239
  'error.E_BAD_RESPONSE': 'The Host returned an unreadable response.',
218
240
  'error.E_INTERNAL': 'An unexpected Host error occurred.',
219
241
  'error.E_NOT_FOUND': 'The conversation no longer exists.',
@@ -315,6 +337,27 @@ window.__ModuleLoader__.load({
315
337
  'trash.status.purging': '清理中断',
316
338
  'trash.status.restored': '已恢复',
317
339
  'trash.status.unreadable': '清单不可读',
340
+ // 归档 → 回收站
341
+ 'auto.title': '会话被归档时',
342
+ 'auto.on': '自动放入回收站',
343
+ 'auto.off': '什么也不做——归档只是把它收起来',
344
+ 'auto.explain': '被归档的会话会自动进入回收站,{days} 天后由宿主彻底删除;关联记忆不会被删除。只有“归档”会触发:关闭后完全不动,把条目取消归档也会退出这个倒计时。',
345
+ 'auto.enabledOn': '已开启——归档的会话会进入回收站。',
346
+ 'auto.enabledOff': '已关闭——归档只是收起会话。',
347
+ 'auto.unasked': '尚未选择——目前不会移动任何会话。',
348
+ 'auto.disabledByOperator': '已被插件配置关闭(autoArchive.enabled: false)。',
349
+ 'auto.noHook': '当前宿主没有提供归档事件,自动流程无法在此运行。',
350
+ 'auto.turnOn': '开启',
351
+ 'auto.turnOff': '关闭',
352
+ 'auto.promptTitle': '新功能:归档的会话可被自动清理',
353
+ 'auto.promptBody': '从本版本起,归档一个会话可以选择把它放进回收站,{days} 天后由宿主彻底删除。关联记忆永不会被删除。你可以随时在下方修改。',
354
+ 'auto.promptAccept': '开启,走回收站',
355
+ 'auto.promptDecline': '不开启,保持原样',
356
+ 'auto.savedOn': '已保存:归档的会话现在会进入回收站。',
357
+ 'auto.savedOff': '已保存:归档不再移动任何会话。',
358
+ 'auto.originAuto': '由归档自动登记',
359
+ 'auto.revived': '已取消归档——不会自动删除',
360
+ 'auto.baseSince': '自 {time} 起生效。',
318
361
  'batch.title': '批量删除',
319
362
  'batch.hint': '每个对话独立事务;失败时仅回滚该对话。',
320
363
  'batch.selected': '已选 {count} 个',
@@ -387,6 +430,7 @@ window.__ModuleLoader__.load({
387
430
  'error.E_DISABLED': '宿主配置已禁用对话删除。',
388
431
  'error.E_TRANSACTION_FAILED': '删除失败,已回滚。',
389
432
  'error.E_MEMORY_FAILED': '记忆存储更新失败;未删除任何内容。',
433
+ 'error.E_MEMORY_GUARD': '该条目由归档自动登记,却带有会话记忆,因此没有删除。若确实要清掉这些记忆,请先恢复条目再显式删除该会话。',
390
434
  'error.E_BAD_RESPONSE': '宿主返回了无法解析的响应。',
391
435
  'error.E_INTERNAL': '宿主发生未预期的错误。',
392
436
  'error.E_NOT_FOUND': '该对话已不存在。',
@@ -1851,6 +1895,11 @@ window.__ModuleLoader__.load({
1851
1895
  const [restoreBusyId, setRestoreBusyId] = useState(null)
1852
1896
  const [purgeBusy, setPurgeBusy] = useState(false)
1853
1897
  const [discardBusyId, setDiscardBusyId] = useState(null)
1898
+ // Archive → recycle bin. The ANSWER lives on the Host (so a cleared browser
1899
+ // cache neither repeats nor hides the question); these are only the
1900
+ // in-flight flag and the success flash for the two buttons.
1901
+ const [autoBusy, setAutoBusy] = useState(false)
1902
+ const [autoNotice, setAutoNotice] = useState(null)
1854
1903
 
1855
1904
  // Two refreshes can overlap (mount, mutation broadcast, and the refresh a
1856
1905
  // purge triggers), and the older response must not overwrite the newer.
@@ -1928,6 +1977,32 @@ window.__ModuleLoader__.load({
1928
1977
  ])
1929
1978
  }, [tab, refreshState, loadTab])
1930
1979
 
1980
+ /**
1981
+ * Answer the "should archiving move conversations to the recycle bin?"
1982
+ * question — or flip it later; it is the same route either way.
1983
+ *
1984
+ * The Host persists it (`<dataDir>/auto-archive.json`), because the row
1985
+ * config is read-only at runtime: a Settings-page switch had nowhere to
1986
+ * live. Nothing is deleted by this call.
1987
+ */
1988
+ const decideAuto = useCallback(async (decision) => {
1989
+ setAutoBusy(true)
1990
+ setAutoNotice(null)
1991
+ try {
1992
+ const response = await api('/archive-auto/ack', { method: 'POST', body: { decision } })
1993
+ setState((current) => (current === null ? current : { ...current, autoArchive: response.autoArchive }))
1994
+ setAutoNotice(decision === 'on' ? 'auto.savedOn' : 'auto.savedOff')
1995
+ // The bin's contents change with the switch (entries stop being
1996
+ // created), so drop the cached listing and re-read the visible tab.
1997
+ loadedRef.current.trash = -1
1998
+ void loadTab('trash', { force: true })
1999
+ } catch (caught) {
2000
+ setError(caught)
2001
+ } finally {
2002
+ setAutoBusy(false)
2003
+ }
2004
+ }, [loadTab])
2005
+
1931
2006
  /**
1932
2007
  * Mark a slice stale without fetching it. Recycle-bin actions call
1933
2008
  * `refreshAfterMutation({ notify: false })`, so no version bump happens:
@@ -2086,6 +2161,65 @@ window.__ModuleLoader__.load({
2086
2161
  return ` · ${left === 0 ? t('trash.expiresSoon') : t('trash.expiresIn', { days: left })}`
2087
2162
  }
2088
2163
 
2164
+ /**
2165
+ * The archive → recycle bin card.
2166
+ *
2167
+ * Three states must be visibly different, because they mean very
2168
+ * different things:
2169
+ * - never asked: NOTHING is moved; the card is the question;
2170
+ * - answered: the answer, with a switch to change it;
2171
+ * - unavailable: the operator vetoed it, or the Host publishes no
2172
+ * archive events (then the promise of a 30-day cleanup is false, so
2173
+ * the card says so instead of showing a dead toggle).
2174
+ */
2175
+ const autoArchiveCard = () => {
2176
+ const auto = state?.autoArchive ?? null
2177
+ if (auto === null) return null
2178
+ const days = auto.retentionDays ?? retentionDays
2179
+ const available = auto.configured === true && auto.hookAvailable !== false
2180
+ const statusText = auto.configured !== true
2181
+ ? t('auto.disabledByOperator')
2182
+ : auto.hookAvailable !== true
2183
+ ? t('auto.noHook')
2184
+ : auto.effective === 'on'
2185
+ ? t('auto.enabledOn')
2186
+ : auto.state === 'off'
2187
+ ? t('auto.enabledOff')
2188
+ : t('auto.unasked')
2189
+ const toggle = available
2190
+ ? h('div', { className: 'dc-auto-actions' },
2191
+ h(Button, {
2192
+ variant: auto.effective === 'on' ? undefined : 'primary',
2193
+ disabled: autoBusy === true || auto.effective === 'on',
2194
+ onClick: () => { void decideAuto('on') },
2195
+ }, t('auto.turnOn')),
2196
+ h(Button, {
2197
+ disabled: autoBusy === true || auto.effective !== 'on',
2198
+ onClick: () => { void decideAuto('off') },
2199
+ }, t('auto.turnOff')))
2200
+ : null
2201
+ return h('div', { className: 'dc-auto' },
2202
+ h('div', { className: 'dc-auto-head' },
2203
+ h('span', { className: 'dc-auto-title' }, t('auto.title')),
2204
+ h('span', { className: auto.effective === 'on' ? 'dc-ok' : 'dc-off' }, statusText)),
2205
+ auto.promptPending === true && available
2206
+ // The one-time upgrade prompt. Rendered from HOST state, so it
2207
+ // appears exactly once across browsers and cannot be suppressed by
2208
+ // clearing storage.
2209
+ ? h('div', { className: 'dc-auto-prompt' },
2210
+ h('div', { className: 'dc-auto-prompt-title' }, t('auto.promptTitle')),
2211
+ h('p', { className: 'dc-hint' }, t('auto.promptBody', { days })),
2212
+ h('div', { className: 'dc-auto-actions' },
2213
+ h(Button, { variant: 'primary', disabled: autoBusy === true, onClick: () => { void decideAuto('on') } }, t('auto.promptAccept')),
2214
+ h(Button, { disabled: autoBusy === true, onClick: () => { void decideAuto('off') } }, t('auto.promptDecline'))))
2215
+ : h('p', { className: 'dc-hint' }, t('auto.explain', { days })),
2216
+ toggle,
2217
+ typeof auto.baseArchivedAt === 'number'
2218
+ ? h('p', { className: 'dc-hint' }, t('auto.baseSince', { time: formatTime(auto.baseArchivedAt) }))
2219
+ : null,
2220
+ autoNotice !== null ? h('p', { className: 'dc-hint dc-ok' }, t(autoNotice)) : null)
2221
+ }
2222
+
2089
2223
  const openFeedback = () => dialogStore.open({
2090
2224
  kind: 'feedback',
2091
2225
  key: 'feedback',
@@ -2117,6 +2251,8 @@ window.__ModuleLoader__.load({
2117
2251
 
2118
2252
  h(ErrorBanner, { error }),
2119
2253
 
2254
+ autoArchiveCard(),
2255
+
2120
2256
  state !== null ? h('div', { className: 'dc-state' },
2121
2257
  h('div', { className: 'dc-state-block' },
2122
2258
  h('div', { className: 'dc-state-title' }, t('state.memory')),
@@ -2160,7 +2296,7 @@ window.__ModuleLoader__.load({
2160
2296
  h('div', { className: 'dc-row-main' },
2161
2297
  h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
2162
2298
  h('div', { className: 'dc-hint' },
2163
- `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${expiryHint(entry)}`)),
2299
+ `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${entry.origin === 'auto-archive' ? ` · ${t('auto.originAuto')}` : ''}${entry.revived === true ? ` · ${t('auto.revived')}` : ''}${expiryHint(entry)}`)),
2164
2300
  h('div', { className: 'dc-row-actions' },
2165
2301
  // The Host already knows whether this entry can be restored;
2166
2302
  // the panel used to offer the button and then show the refusal.
@@ -2282,6 +2418,15 @@ window.__ModuleLoader__.load({
2282
2418
  .dc-dialog-title{margin:0 0 8px;font-size:16px;font-weight:600;line-height:1.4}
2283
2419
  .dc-dialog-body{margin:0 0 12px;font-size:13px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.6}
2284
2420
  .dc-section{margin-top:10px}
2421
+ /* Archive → recycle bin card. Neutral chrome (it is a setting, not a warning);
2422
+ the prompt variant gets the brand border the feedback bar uses so the one-time
2423
+ question is visibly a question rather than another status line. */
2424
+ .dc-auto{margin:12px 0 4px;padding:12px 14px;border:1px solid var(--dsw-alias-border-secondary,#e3e5ea);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
2425
+ .dc-auto-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap}
2426
+ .dc-auto-title{font-weight:600}
2427
+ .dc-auto-prompt{margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:8px;background:var(--dsw-alias-bg-layer-2,#fff)}
2428
+ .dc-auto-prompt-title{font-weight:600;margin-bottom:6px}
2429
+ .dc-auto-actions{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
2285
2430
  .dc-meta{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);margin-bottom:10px}
2286
2431
  .dc-modes{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
2287
2432
  .dc-mode{display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;cursor:pointer;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
@@ -75,6 +75,15 @@
75
75
  "batchDoubleConfirm": { "type": "boolean", "default": true }
76
76
  }
77
77
  },
78
+ "autoArchive": {
79
+ "type": "object",
80
+ "additionalProperties": false,
81
+ "description": "Archive → recycle bin. An archived conversation automatically becomes a recycle-bin entry, and the retention sweep purges it after trashRetentionDays. Those entries never move conversation memory. The switch is genuinely three-state at runtime: this config is the operator veto and the pre-selected answer, while the user's own answer lives in <dataDir>/auto-archive.json, so Settings can toggle it without editing this file. Effective behaviour = enabled AND the user answered 'on': an unanswered prompt never starts a countdown.",
82
+ "properties": {
83
+ "enabled": { "type": "boolean", "default": true, "description": "Master switch for the automatic path, and the default answer pre-selected in the one-time prompt. false removes the feature (and the prompt) for this deployment regardless of any stored answer." },
84
+ "askOnUpgrade": { "type": "boolean", "default": true, "description": "Show the one-time 'should archived conversations be moved to the recycle bin?' prompt in Settings after the upgrade. false never asks." }
85
+ }
86
+ },
78
87
  "feedback": {
79
88
  "type": "object",
80
89
  "additionalProperties": false,
package/cordis.patch.yml CHANGED
@@ -83,6 +83,22 @@
83
83
  # Should a checked "delete associated memory" move entries to the
84
84
  # plugin trash (recoverable) or leave them untouched?
85
85
  deleteMode: trash
86
+ # Archive → recycle bin. An archived conversation automatically becomes a
87
+ # recycle-bin entry, which the retention sweep above purges after
88
+ # `trashRetentionDays` days. Those entries NEVER touch conversation
89
+ # memory, whatever `memory.deleteMode` says.
90
+ #
91
+ # `enabled` is both the operator veto and the pre-selected answer in the
92
+ # one-time prompt shown after an upgrade: the effective behaviour is
93
+ # `enabled` AND the user having answered "on" (state lives in
94
+ # `<dataDir>/auto-archive.json`, so Settings can flip it at runtime
95
+ # without editing this file). Set false here to remove the feature for
96
+ # every user of this deployment, prompt included.
97
+ autoArchive:
98
+ enabled: true
99
+ # false = never show the one-time prompt (useful when you deploy for
100
+ # others and do not want an in-app question).
101
+ askOnUpgrade: true
86
102
  ui:
87
103
  showRowAction: true
88
104
  showHeaderAction: true