dsh-archived-chats 1.3.1 → 1.3.3

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.zh-CN.md CHANGED
@@ -66,7 +66,7 @@ dsh plugin --profile web update dsh-archived-chats
66
66
  ## 安全设计
67
67
 
68
68
  - **数据只在本机:** 插件元数据、回收记录、策略和已验证快照均保存在 `$DSH_HOME/plugin-data/archived-chats/`,不会上传或云同步。
69
- - **不静默覆盖:** 导入和旧快照恢复只创建或选择无冲突 ID,绝不覆盖已有会话。
69
+ - **不静默覆盖:** 导入和快照恢复只创建或选择无冲突 ID,绝不覆盖已有会话。
70
70
  - **删除必须明确:** 普通移除会在快照保护后进入回收站;只有经过确认的永久删除操作才会物理清除。
71
71
  - **可选自动清理:** 默认关闭,确认保留期限后,DSH 运行时自动永久删除到期的回收聊天,启动后补清理。开启或缩短期限会先列出受影响聊天供确认;旧版设置不会自动开启。
72
72
  - **确认工作区归档:** 选择器只显示有可归档会话的工作区,支持单选、多选和可再次点击取消的“全选”,并统一从右下角“确定”继续。插件在后台分别准备精确集合,跳过准备期间变为空的工作区,再显示一次汇总确认。只有检查到真实 `turn/start` 的会话才符合条件;空白的新会话窗口及无法确认内容的会话都会跳过。每个工作区保留独立的 5 分钟单次凭据,不会纳入之后新建的聊天;运行中的聊天只会跳过,绝不停止或移动。
@@ -78,11 +78,11 @@ dsh plugin --profile web update dsh-archived-chats
78
78
 
79
79
  | Host 能力 | 插件行为 |
80
80
  | --- | --- |
81
- | 归档与会话读取 | 浏览、搜索、预览、回收站中的旧快照清单、空间分账和会话血缘。 |
81
+ | 归档与会话读取 | 浏览、搜索、预览、回收站中的保护快照清单、空间分账和会话血缘。 |
82
82
  | `settings.section` + 公开 `archiveSession` | 插件自己的设置页提供仅展示可归档项的多工作区选择器和一次汇总确认,不依赖工作区菜单扩展 slot;缺少归档能力时,准备请求返回 `workspace-archive-unsupported`,且不作任何修改。 |
83
83
  | 附件读取 | 对话和快照预览可显示已存储图片;缺少时文本内容仍可阅读。 |
84
84
  | 会话独立日志位置 | 回收站永久删除使用持久化后端公开的 `locate(meta)` 能力。后端不提供会话独立位置时不支持永久删除;失败的条目保留在列表中并显示具体原因。 |
85
- | 公开会话 writer | ZIP 导入、旧快照恢复和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
85
+ | 公开会话 writer | ZIP 导入、快照恢复和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
86
86
  | 缺少写入能力 | 操作返回 `restore-unsupported`,不会写入或覆盖数据。 |
87
87
 
88
88
  降级到不识别统一回收站或新版快照状态的版本前,请备份 `$DSH_HOME/plugin-data/archived-chats/`。
@@ -101,10 +101,10 @@ dsh plugin --profile web update dsh-archived-chats
101
101
  </tr>
102
102
  <tr>
103
103
  <td><img src="assets/screenshots/preview-03.png" alt="仅展示可归档项并支持全选的多工作区归档选择器"><br><sub>工作区归档选择器</sub></td>
104
- <td><img src="assets/screenshots/preview-04.png" alt="带已存储图片的旧版快照原生只读预览"><br><sub>原生只读预览</sub></td>
104
+ <td><img src="assets/screenshots/preview-04.png" alt="带已存储图片的保护快照原生只读预览"><br><sub>原生只读预览</sub></td>
105
105
  </tr>
106
106
  <tr>
107
- <td><img src="assets/screenshots/preview-05.png" alt="包含旧版快照及单条和工作区操作的统一回收站"><br><sub>统一回收站</sub></td>
107
+ <td><img src="assets/screenshots/preview-05.png" alt="包含保护快照及单条和工作区操作的统一回收站"><br><sub>统一回收站</sub></td>
108
108
  <td><img src="assets/screenshots/preview-06.png" alt="清空所有工作区回收站前的不可恢复确认"><br><sub>清空回收站确认</sub></td>
109
109
  </tr>
110
110
  <tr>
@@ -134,7 +134,7 @@ Session Archive 目前处于积极维护状态。最新 npm 稳定版会接收
134
134
  npm test
135
135
  ```
136
136
 
137
- 测试覆盖 Host 与浏览器行为、导出导入、旧快照恢复、回收站、保留策略、全文搜索、响应式布局、公开类型、包内容和仓库卫生。测试只使用隔离临时数据,不读取真实会话。
137
+ 测试覆盖 Host 与浏览器行为、导出导入、快照恢复、回收站、保留策略、全文搜索、响应式布局、公开类型、包内容和仓库卫生。测试只使用隔离临时数据,不读取真实会话。
138
138
 
139
139
  ## 卸载
140
140
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  - Host 服务层位于 lib/index.js,运行在 DSH Web 宿主中,读取工作区注册表和会话持久层,并提供本地 HTTP 路由。
12
12
  - 浏览器客户端位于 lib/client.js,通过 settings.section 注册「会话档案」设置页,负责展示状态和发起操作。
13
- - 纯领域逻辑拆分在 lib/export.js、lib/import.js、lib/restore.js、lib/metadata.js、lib/search.js、lib/stats.js、lib/insights.js、lib/retention.js、lib/retention-service.js、lib/auto-retention.js、lib/lineage.js 和 lib/workspace-bulk-archive.js 中。lib/persistence-compat.js 将新版 Host 的句柄读取面收敛为插件内部只读视图;lib/history.js 负责旧快照安全清单和预览授权,lib/history-restore.js 负责单次确认的恢复为副本事务,lib/legacy-recycle.js 将旧快照投影进统一回收站并保存迁移状态。lib/trash.js 负责版本化回收目录,lib/snapshot.js 负责可验证快照,lib/recycle.js 组合普通回收生命周期。
13
+ - 纯领域逻辑拆分在 lib/export.js、lib/import.js、lib/restore.js、lib/metadata.js、lib/search.js、lib/stats.js、lib/insights.js、lib/retention.js、lib/retention-service.js、lib/auto-retention.js、lib/lineage.js 和 lib/workspace-bulk-archive.js 中。lib/persistence-compat.js 将新版 Host 的句柄读取面收敛为插件内部只读视图。lib/trash.js 负责版本化回收目录,lib/snapshot.js 负责可验证快照,lib/recycle.js 组合普通回收生命周期。
14
14
 
15
15
  浏览器不直接访问会话文件。所有读取和写入都经 Host 路由完成。
16
16
 
@@ -71,13 +71,13 @@ $DSH_HOME/plugin-data/archived-chats/retention.json
71
71
  $DSH_HOME/plugin-data/archived-chats/snapshots/
72
72
  ~~~
73
73
 
74
- 元数据、回收目录和旧快照迁移目录均带版本号,写入通过队列串行化并用临时文件原子替换。无法解析或不支持的 `trash.json` 保留原始字节、不隐藏任何归档会话,并禁用回收修改;无法读取 `legacy-recycle.json` 时拒绝旧快照迁移操作,不猜测状态。
74
+ 元数据和回收目录均带版本号,写入通过队列串行化并用临时文件原子替换。无法解析或不支持的 `trash.json` 保留原始字节、不隐藏任何归档会话,并禁用回收修改。
75
75
 
76
76
  stats 路由以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒。测量失败只标记当前行不可用,不阻塞列表和其他操作;删除会使对应缓存失效。
77
77
 
78
78
  insights 将会话目录测量与流式校验的快照清单分账,重复附件只按快照内已验证 SHA-256 统计;浏览器只在摘要卡片中显示总量,会话目录和快照明细通过有界、可搜索弹窗按需呈现。
79
79
 
80
- retention.json 使用 version 2 schema,并加入显式 `recycleAutoDelete` 开关;version 1 文件继续兼容读取,其开关被强制视为 false 且不会静默改写磁盘。只有回收站保留天数参与清理规划,旧版快照数量、年龄与容量字段不再规划快照删除。policy/preview 为开启或缩短期限生成五分钟单次 token/nonce,绑定当前策略、拟保存策略与到期候选;policy 保存支持 `{ policy, confirmation: { token, nonce } }`,在生命周期锁内复核并保存,保存请求本身不执行删除。自动任务在启动恢复完成后运行,此后每分钟串行检查;每条候选仍委托统一 recycle purge,在其生命周期锁内重新核对设置与记录后才写入 purge-pending 意图。停用插件会停止新任务;已有永久删除意图继续重试。旧版 retention/preview 与 retention/apply 手动 API 保持兼容,但界面不再提供额外预览清理步骤。
80
+ retention.json 使用 version 2 schema,并加入显式 `recycleAutoDelete` 开关;version 1 文件继续兼容读取,其开关被强制视为 false 且不会静默改写磁盘。只有回收站保留天数参与清理规划,旧版保留策略的快照数量、年龄与容量字段不再规划快照删除。policy/preview 为开启或缩短期限生成五分钟单次 token/nonce,绑定当前策略、拟保存策略与到期候选;policy 保存支持 `{ policy, confirmation: { token, nonce } }`,在生命周期锁内复核并保存,保存请求本身不执行删除。自动任务在启动恢复完成后运行,此后每分钟串行检查;每条候选仍委托统一 recycle purge,在其生命周期锁内重新核对设置与记录后才写入 purge-pending 意图。停用插件会停止新任务;已有永久删除意图继续重试。旧版 retention/preview 与 retention/apply 手动 API 保持兼容,但界面不再提供额外预览清理步骤。
81
81
 
82
82
  lineage 只用持久化 parentSession 建树,不修改会话头;对已经进入聚焦关系树但没有安全标题的活动来源节点,最多按需读取 100 个标题事件。其 5,000 节点上限约束的是**实际投影出的**关系图,而不是 Host 的会话库:`focusIds` 已把输出收窄为归档与回收聊天加上解释它们所需的上下文,因此 100,000 个会话、25 条归档只投影 25 个节点。会话头字段按节点逐项收敛而不是整图校验——未预期的 `origin` 取值、缺失的 `createdAt`、变化的数值类型,各自只把该节点降级为公开 LineageNode 已允许的取值,只有身份不可用才丢弃该行。为一个不认识的会话头拒绝整张图,会让 Host 侧的变化在插件毫无改动的情况下直接停用该面板;畸形的工作区与回收记录出于同样理由被跳过。
83
83
 
@@ -89,7 +89,9 @@ preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chat
89
89
 
90
90
  跨会话搜索的持久层读取并发上限为 4,达到命中上限后不再调度后续批次,浏览器的新搜索会中止旧请求;单个会话失败会记入 skipped,其他命中仍正常返回。规范投影使用 30 秒 TTL、64 会话 LRU,并限制单段 256 Ki Unicode 码点、单消息 1 Mi 码点/1,000 段、单会话 10,000 条投影消息;结构化未知值在 stringify 前即受深度、节点和字符预算约束。超出投影边界的内容会安全截断且不会常驻缓存。取消归档、删除和恢复会使相关缓存失效。
91
91
 
92
- ## 旧快照兼容与统一回收站
92
+ ## 统一回收站与保护快照
93
+
94
+ 会话删除进入回收站,保留保护快照并支持恢复。删除前会验证来源聊天是否仍在活跃工作区中显示,从而防止误删。回收记录永久删除后,未被引用的快照会在启动时自动清理。
93
95
 
94
96
  归档不再调用 `history/capture`,该兼容路由拒绝新建历史版本。升级时保留已有快照;`legacy-recycle.js` 将未被普通回收记录引用的快照投影为 `legacy:<snapshotId>` 回收条目,浏览器直接在统一回收站中展示。History 清单、预览、恢复和删除路由继续服务迁移与兼容调用方;回收站仍可创建恢复所需的保护快照。
95
97
 
@@ -97,7 +99,7 @@ preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chat
97
99
 
98
100
  `history-restore.js` 先完整验证快照,用 Host 生成新会话 ID,再签发五分钟、单次使用的 token/nonce。确认时先消费凭据并重验 manifest;然后依次创建持久会话、重写会话/附件身份、附加事件、恢复工作区和元数据,最后才写入归档注册表。统一回收服务在提交后将迁移状态标记为 `restore-complete`,再删除已恢复快照和迁移记录;清理失败只保留待清理状态,不会撤销已经成功创建的归档副本。任一插件控制的恢复边界失败都按逆序回滚;来源会话与快照在提交前始终不变,也不声称删除了 Host 全局附件对象。
99
101
 
100
- 旧快照永久删除与清空回收站都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与普通回收保护关系。仍被普通回收记录引用的快照不会重复投影为旧快照条目。降级快照无法预览或恢复,但可通过统一回收站永久删除,避免无法回收磁盘字节。删除会物理移除插件快照及其附件副本,但不修改来源聊天或其他快照。
102
+ 删除操作进入统一生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与普通回收保护关系。降级快照无法预览或恢复,但可通过统一回收站永久删除,避免无法回收磁盘字节。删除会物理移除插件快照及其附件副本,但不修改来源聊天或其他快照。
101
103
 
102
104
  ## 导出流程
103
105
 
@@ -124,17 +126,17 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
124
126
  5. restore.js 通过能力探测的适配器写入会话、元数据和归档状态。
125
127
  6. 任一步骤失败都回滚暂存数据,不覆盖已有会话。
126
128
 
127
- 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是旧快照恢复写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
129
+ 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是快照恢复写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
128
130
 
129
131
  ## 回收与保护快照生命周期
130
132
 
131
133
  `trash.json` 的合法状态只有 `trashed`、`purge-pending`、`degraded`。合法转换为 `missing -> trashed`、`trashed/degraded -> purge-pending`,以及任一现有状态在事务成功后移除。`purge-pending` 不得恢复。
132
134
 
133
- 保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个普通回收记录只引用一个活跃快照;恢复或重复回收后不再被普通记录引用的有效快照会自动显示为统一回收站中的旧快照条目,直到恢复为副本或永久删除。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。Windows 在目标仍被其他句柄打开时无法原子替换文件或移除目录项(索引服务或杀毒扫描即可导致),因此替换与递归删除会在该平台上、且仅在该平台上,对瞬时的 `EPERM` / `EACCES` / `EBUSY` 做有界重试:POSIX 上这些码表示永久条件,重试只会延后同一个失败。快照发布还会在 rename 被拒时探测目标目录再判定为冲突,因为 Windows 把「rename 到已存在目录」报成 `EPERM` 而不是 `EEXIST`。路径包含判定使用平台分隔符并拒绝绝对结果,因此 Windows 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
135
+ 保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个普通回收记录只引用一个活跃快照;恢复或重复回收后不再被普通记录引用的有效快照会在启动时自动清理。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。Windows 在目标仍被其他句柄打开时无法原子替换文件或移除目录项(索引服务或杀毒扫描即可导致),因此替换与递归删除会在该平台上、且仅在该平台上,对瞬时的 `EPERM` / `EACCES` / `EBUSY` 做有界重试:POSIX 上这些码表示永久条件,重试只会延后同一个失败。快照发布还会在 rename 被拒时探测目标目录再判定为冲突,因为 Windows 把「rename 到已存在目录」报成 `EPERM` 而不是 `EEXIST`。路径包含判定使用平台分隔符并拒绝绝对结果,因此 Windows 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
134
136
 
135
137
  移入顺序为:校验归档所有权 → 处置/停放运行中会话 → 捕获并验证快照 → 再次校验所有权 → 原子写入 `trashed` 记录 → 使缓存失效。普通移入不删除持久层文件。
136
138
 
137
- 恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除普通回收记录,不重写持久层;随后失去普通回收引用的保护快照会作为旧快照条目显示在统一回收站。原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
139
+ 恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除普通回收记录,不重写持久层;随后失去普通回收引用的保护快照会继续显示在统一回收站。原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
138
140
 
139
141
  永久删除在任何物理写入前持久化 `purge-pending`,接着删除该来源的全部快照,然后删除原会话,最后移除回收记录。会话删除刻意放在最后:在它之前失败会保留完好的原件和可继续完成的记录,而不是留下一条会话已消失、既不能恢复也不能完成的 `purge-pending`。快照清扫按 manifest 身份归属每个已发布快照,因此校验失败但确属该会话的快照仍会被删除,而无关的无法校验快照会被跳过而不是中断整个清扫——快照库别处的损坏绝不能让永久删除变成不可能。回收记录还会点名自己的快照 id,覆盖损坏到无法归属的那一个。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。物理删除另外要求定位到的文件位于以该会话自身命名的目录中,因此当后端布局让多个会话共享同一父目录时,那个父目录永远不会被删除。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
140
142
 
@@ -149,7 +151,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
149
151
  - 标签备注编辑器。
150
152
  - 行级操作负责单条聊天,依次为预览、编辑标签备注、永久删除和取消归档,不提供单条导出;归档工作区菜单依次提供全部导出、全部取消归档、全部永久删除和全部移至回收站;回收站工作区菜单提供全部恢复和全部永久删除。两页均无多选状态、行选择框或批量工具栏。
151
153
  - 已归档和回收站标题行分别提供跨工作区的全部删除与清空回收站;后者覆盖所有回收会话与保护快照。回收站行级恢复和永久删除使用具备名称与提示的图标按钮。
152
- - 已归档、回收站、空间与策略、来源与分支四标签。旧快照在回收站中提供预览、恢复为新的归档副本和确认删除;恢复确认的初始焦点位于取消,token/nonce 不进入渲染树。空间与关系视图保留有界弹窗和只读关系投影。
154
+ - 已归档、回收站、空间与策略、来源与分支四标签。保护快照在回收站中提供预览、恢复为新的归档副本和确认删除;恢复确认的初始焦点位于取消,token/nonce 不进入渲染树。空间与关系视图保留有界弹窗和只读关系投影。
153
155
  - 导入预览、冲突禁用和恢复结果。
154
156
  - 响应式设置页标记和侧边栏刷新注入面。
155
157
 
@@ -164,7 +166,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
164
166
  ## 安全和失败策略
165
167
 
166
168
  - 所有状态变更路由都要求 POST 和 guard header。
167
- - 兼容 History 响应与统一回收站中的旧快照条目不包含工作区/快照/附件路径、原始事件、备注或确认 token;日志只记 ID 和稳定代码。
169
+ - 兼容 History 响应与统一回收站中的保护快照条目不包含工作区/快照/附件路径、原始事件、备注或确认 token;日志只记 ID 和稳定代码。
168
170
  - 工作区归档响应只公开安全的工作区 ID/标题、符合条件数量、已确认预览中的会话 ID/标题/时间和稳定的逐项结果;工作区路径、事件正文、备注、附件路径与确认凭据不会进入日志或渲染结果。
169
171
  - 导入限制 ZIP 大小、条目数量、路径格式、版本和 JSON 结构,拒绝遍历、重复和原型污染字段。
170
172
  - 普通删除从不调用物理清除;仅已提交回收记录可进入 purge。归档列表还提供单独确认的直接永久删除操作,跳过快照和回收站,但复用同一套会话目录边界、崩溃恢复和失败保护;普通删除仍默认可恢复。
@@ -178,7 +180,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
178
180
 
179
181
  当前 v1 快照和 ZIP schema 不能保存 `inheritedEventCount`。新版读取句柄报告大于 0 的继承前缀时,适配器会在读取事件前以 `session-inspection-unsupported` 拒绝并关闭句柄,避免把分支历史静默展平。工作区归档不再依赖快照抓取;回收保护与 ZIP 导出仍会拒绝不支持的继承事件数据。这个 UI 变更不扩展快照/恢复协议。
180
182
 
181
- 兼容旧写入面的 Host 仍可让导入、旧快照恢复和原件丢失时的快照回退通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。降级到不识别统一回收站或新版快照状态的版本前,应备份整个插件数据目录。
183
+ 兼容旧写入面的 Host 仍可让导入、保护快照恢复和原件丢失时的快照回退通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。降级到不识别统一回收站或新版快照状态的版本前,应备份整个插件数据目录。
182
184
 
183
185
  测试覆盖:
184
186
 
@@ -190,7 +192,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
190
192
  - search.js 的消息投影、Unicode 搜索、分页、部分失败与 TTL/LRU 缓存。
191
193
  - trash.js、snapshot.js 和 recycle.js 的格式验证、并发、恢复、回滚、崩溃意图和旧标记迁移。
192
194
  - insights.js、retention.js、retention-service.js、auto-retention.js 和 lineage.js 的可信分账、策略边界、短效授权、重检、自动任务生命周期和有界图投影。
193
- - history.js、history-restore.js 和 legacy-recycle.js 的旧快照保留、统一回收投影、缓存失效、快照授权、单次确认、事务回滚与来源不变式。
195
+ - history.js、history-restore.js 和 legacy-recycle.js 的保护快照保留、统一回收投影、缓存失效、快照授权、单次确认、事务回滚与来源不变式。
194
196
  - Host 路由和浏览器设置页的冒烟及响应式行为。
195
197
 
196
198
  运行:
package/lib/client.js CHANGED
@@ -196,14 +196,14 @@ window.__ModuleLoader__.load({
196
196
  "trash.listUnverified": "回收站目录无法读取:下面的列表可能包含已删除的聊天,归档修改已暂停。",
197
197
  "insights.loading": "正在分析空间…",
198
198
  "insights.error": "空间分析暂不可用",
199
- "insights.scopeNote": "这里只统计已归档、回收站会话,以及本插件为它们创建并继续保留的恢复快照。恢复聊天后快照仍可保留,所以归档列表为空时这里仍可能有数据。",
199
+ "insights.scopeNote": "这里只统计已归档会话和回收站当前引用的保护快照。归档列表为空时仍可能有保护快照,因为回收记录会保留快照以备恢复。未被回收记录引用的保护快照会在启动时自动清理。",
200
200
  "insights.sessions": "会话目录",
201
201
  "insights.snapshots": "保护快照",
202
202
  "insights.total": "已统计总量",
203
203
  "insights.duplicate": "重复快照附件",
204
204
  "insights.unavailable": "不可用会话 / 降级快照",
205
205
  "insights.snapshot.active": "回收站使用中的恢复快照",
206
- "insights.snapshot.history": "已保留的恢复快照",
206
+ "insights.snapshot.history": "未被回收记录引用的快照",
207
207
  "insights.snapshot.degraded": "不可用的恢复快照",
208
208
  "insights.snapshot.original": "原会话",
209
209
  "insights.snapshot.created": "创建时间",
@@ -218,8 +218,8 @@ window.__ModuleLoader__.load({
218
218
  "insights.scope.archive": "已归档",
219
219
  "insights.scope.trash": "回收站",
220
220
  "insights.detailsEmpty": "没有匹配的明细",
221
- "retention.history": "每个原会话保留的恢复快照",
222
- "retention.snapshotAge": "历史快照最长保留天数",
221
+ "retention.history": "回收站保护快照",
222
+ "retention.snapshotAge": "已取消的快照保留天数",
223
223
  "retention.quota": "快照容量上限(字节)",
224
224
  "retention.recycleAge": "回收站自动清理",
225
225
  "retention.custom": "自定义",
@@ -237,7 +237,7 @@ window.__ModuleLoader__.load({
237
237
  "retention.autoConfirm": "确认并保存",
238
238
  "retention.autoBody": "每条聊天在回收站保留 {days} 天后将自动永久删除,无法恢复。已有 {count} 条聊天达到期限,保存后会在下一次检查时清理。",
239
239
  "retention.confirmationRetry": "设置或待清理聊天已变化,或确认已过期。请重新保存并核对。",
240
- "retention.snapshotCandidate": "历史快照清理",
240
+ "retention.snapshotCandidate": "保护快照由启动流程自动清理",
241
241
  "retention.trashCandidate": "永久删除回收站会话",
242
242
  "retention.projected": "仅列出按已保存策略可清理的项目;勾选并确认后才会永久删除。",
243
243
  "retention.purgeWarning": "所选回收站项会永久删除原会话及其全部保护快照,无法撤销。",
@@ -331,7 +331,6 @@ window.__ModuleLoader__.load({
331
331
  "preview.copied": "已复制",
332
332
  "preview.imageUnavailable": "图片不可用",
333
333
  "preview.readOnly": "只读预览",
334
- "preview.snapshotAt": "快照时间",
335
334
  "preview.toolPending": "等待中",
336
335
  "preview.toolRunning": "运行中",
337
336
  "preview.toolComplete": "已完成",
@@ -376,7 +375,6 @@ window.__ModuleLoader__.load({
376
375
  "trash.status.parked": "会话已停用,可恢复",
377
376
  "trash.status.degraded": "快照降级",
378
377
  "trash.status.purgePending": "永久删除待恢复",
379
- "trash.status.legacy": "旧版恢复副本",
380
378
  "trash.attachments": "附件",
381
379
  "trash.confirm.title": "永久删除回收站中的会话?",
382
380
  "trash.confirm.body": "这将删除原会话和保护快照,且无法撤销",
@@ -427,7 +425,7 @@ window.__ModuleLoader__.load({
427
425
  "workspaceArchive.result": "批量归档结果",
428
426
  "workspaceArchive.archived": "已归档",
429
427
  "workspaceArchive.failed": "失败",
430
- "workspaceArchive.snapshotFailed": "历史快照未保存",
428
+ "workspaceArchive.snapshotFailed": "保护快照未保存",
431
429
  "workspaceArchive.done": "完成",
432
430
  "workspaceArchive.unsupported": "当前 DSH 版本不支持按项目批量归档。",
433
431
  "workspaceArchive.error": "无法完成按项目批量归档,请重试。",
@@ -439,8 +437,8 @@ window.__ModuleLoader__.load({
439
437
  "workspaceArchive.reason.archive-failed": "归档失败",
440
438
  "workspaceArchive.reason.archive-uncommitted": "归档未完成",
441
439
  "workspaceArchive.reason.lifecycle-failed": "操作未完成",
442
- "workspaceArchive.status.captured": "历史快照已保存",
443
- "workspaceArchive.status.snapshot-failed": "历史快照未保存",
440
+ "workspaceArchive.status.captured": "保护快照已保存",
441
+ "workspaceArchive.status.snapshot-failed": "保护快照未保存",
444
442
  "workspaceArchive.reason.unknown": "未完成",
445
443
  "workspaceArchive.retry": "重新准备"
446
444
  };
@@ -465,14 +463,14 @@ window.__ModuleLoader__.load({
465
463
  "trash.listUnverified": "The Recycle Bin catalog is unreadable: this list may include deleted chats, and archive changes are paused.",
466
464
  "insights.loading": "Analyzing storage…",
467
465
  "insights.error": "Storage insights are unavailable",
468
- "insights.scopeNote": "This page measures archived and recycled chats plus recovery snapshots this plugin created and retained for them. Restoring a chat may retain its snapshot, so storage can remain when the archive list is empty.",
466
+ "insights.scopeNote": "This page measures archived chats and protection snapshots currently referenced by Recycle Bin records. Protection snapshots can remain even when the archive list is empty, because recycle records retain snapshots for restoration. Unreferenced legacy snapshots are removed during startup.",
469
467
  "insights.sessions": "Session directories",
470
468
  "insights.snapshots": "Protection snapshots",
471
469
  "insights.total": "Measured total",
472
470
  "insights.duplicate": "Repeated snapshot attachments",
473
471
  "insights.unavailable": "Unavailable sessions / degraded snapshots",
474
472
  "insights.snapshot.active": "Recovery snapshot in use by Recycle Bin",
475
- "insights.snapshot.history": "Retained recovery snapshot",
473
+ "insights.snapshot.history": "Unreferenced snapshot",
476
474
  "insights.snapshot.degraded": "Unavailable recovery snapshot",
477
475
  "insights.snapshot.original": "Original chat",
478
476
  "insights.snapshot.created": "Created",
@@ -487,8 +485,8 @@ window.__ModuleLoader__.load({
487
485
  "insights.scope.archive": "Archived",
488
486
  "insights.scope.trash": "Recycle Bin",
489
487
  "insights.detailsEmpty": "No matching details",
490
- "retention.history": "Retained recovery snapshots per original chat",
491
- "retention.snapshotAge": "Historical snapshot maximum age in days",
488
+ "retention.history": "Recycle Bin protection snapshots",
489
+ "retention.snapshotAge": "Retired snapshot age setting",
492
490
  "retention.quota": "Snapshot quota in bytes",
493
491
  "retention.recycleAge": "Auto-empty Recycle Bin",
494
492
  "retention.custom": "Custom",
@@ -506,7 +504,7 @@ window.__ModuleLoader__.load({
506
504
  "retention.autoConfirm": "Confirm and save",
507
505
  "retention.autoBody": "Each chat will be permanently deleted after {days} days in the Recycle Bin and cannot be recovered. {count} existing chats have expired and will be removed at the next check after saving.",
508
506
  "retention.confirmationRetry": "The policy or affected chats changed, or the confirmation expired. Save again to review the current details.",
509
- "retention.snapshotCandidate": "Historical snapshot cleanup",
507
+ "retention.snapshotCandidate": "Legacy snapshots are cleaned at startup",
510
508
  "retention.trashCandidate": "Permanently delete recycled chat",
511
509
  "retention.projected": "Items are based on the saved policy. Permanent deletion requires selection and confirmation.",
512
510
  "retention.purgeWarning": "Selected recycle items permanently remove the original chat and every protection snapshot. This cannot be undone.",
@@ -600,7 +598,6 @@ window.__ModuleLoader__.load({
600
598
  "preview.copied": "Copied",
601
599
  "preview.imageUnavailable": "Image unavailable",
602
600
  "preview.readOnly": "Read-only preview",
603
- "preview.snapshotAt": "Snapshot",
604
601
  "preview.toolPending": "Pending",
605
602
  "preview.toolRunning": "Running",
606
603
  "preview.toolComplete": "Complete",
@@ -645,7 +642,6 @@ window.__ModuleLoader__.load({
645
642
  "trash.status.parked": "Session parked and restorable",
646
643
  "trash.status.degraded": "Snapshot degraded",
647
644
  "trash.status.purgePending": "Permanent deletion recovery pending",
648
- "trash.status.legacy": "Legacy recovery copy",
649
645
  "trash.attachments": "attachments",
650
646
  "trash.confirm.title": "Permanently delete recycled chat?",
651
647
  "trash.confirm.body": "This removes the original chat and protection snapshot and cannot be undone",
@@ -696,7 +692,7 @@ window.__ModuleLoader__.load({
696
692
  "workspaceArchive.result": "Workspace archive result",
697
693
  "workspaceArchive.archived": "Archived",
698
694
  "workspaceArchive.failed": "Failed",
699
- "workspaceArchive.snapshotFailed": "History snapshot was not saved",
695
+ "workspaceArchive.snapshotFailed": "Protection snapshot was not saved",
700
696
  "workspaceArchive.done": "Done",
701
697
  "workspaceArchive.unsupported": "This DSH version does not support workspace bulk archive.",
702
698
  "workspaceArchive.error": "Could not complete workspace bulk archive. Try again.",
@@ -708,8 +704,8 @@ window.__ModuleLoader__.load({
708
704
  "workspaceArchive.reason.archive-failed": "Archive failed",
709
705
  "workspaceArchive.reason.archive-uncommitted": "Archive did not complete",
710
706
  "workspaceArchive.reason.lifecycle-failed": "Operation did not complete",
711
- "workspaceArchive.status.captured": "History snapshot saved",
712
- "workspaceArchive.status.snapshot-failed": "History snapshot was not saved",
707
+ "workspaceArchive.status.captured": "Protection snapshot saved",
708
+ "workspaceArchive.status.snapshot-failed": "Protection snapshot was not saved",
713
709
  "workspaceArchive.reason.unknown": "Not completed",
714
710
  "workspaceArchive.retry": "Prepare again"
715
711
  };
@@ -1027,7 +1023,6 @@ window.__ModuleLoader__.load({
1027
1023
  function trashStatusLabel(t, row) {
1028
1024
  if (row?.state === "purge-pending") return t("trash.status.purgePending");
1029
1025
  if (row?.state === "degraded") return t("trash.status.degraded");
1030
- if (row?.sourceKind === "legacy-snapshot") return t("trash.status.legacy");
1031
1026
  if (row?.liveDisposition === "parked") return t("trash.status.parked");
1032
1027
  return t("trash.status.ready");
1033
1028
  }
@@ -1426,26 +1421,6 @@ window.__ModuleLoader__.load({
1426
1421
  return res.json();
1427
1422
  }
1428
1423
 
1429
- async function fetchHistoryPreview(snapshotId, offset = 0, limit = 50, signal) {
1430
- return post("/history/preview", { snapshotId, offset, limit }, signal);
1431
- }
1432
-
1433
- async function fetchHistoryImage(snapshotId, attachment, signal) {
1434
- const res = await fetch(`${API_BASE}/history/preview/image`, {
1435
- method: "POST",
1436
- headers: { "content-type": "application/json", [GUARD_HEADER]: "1" },
1437
- body: JSON.stringify({ snapshotId, attachment }),
1438
- signal,
1439
- });
1440
- if (!res.ok) {
1441
- const parsed = await res.json().catch(() => ({}));
1442
- const error = new Error(parsed.message || parsed.error || `HTTP ${res.status}`);
1443
- error.body = parsed;
1444
- throw error;
1445
- }
1446
- return res.blob();
1447
- }
1448
-
1449
1424
  async function fetchInsights(signal) {
1450
1425
  const res = await fetch(`${API_BASE}/insights`, { cache: "no-store", signal });
1451
1426
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -2401,9 +2376,7 @@ window.__ModuleLoader__.load({
2401
2376
  started = true;
2402
2377
  setState({ status: "loading", url: null });
2403
2378
  try {
2404
- const blob = scope === "history"
2405
- ? await fetchHistoryImage(sessionId, attachment, controller.signal)
2406
- : await fetchArchiveImage(sessionId, attachment.attachmentId, controller.signal, scope);
2379
+ const blob = await fetchArchiveImage(sessionId, attachment.attachmentId, controller.signal, scope);
2407
2380
  if (disposed) return;
2408
2381
  objectUrl = (window.URL ?? URL).createObjectURL(blob);
2409
2382
  setState({ status: "ready", url: objectUrl });
@@ -2595,9 +2568,7 @@ window.__ModuleLoader__.load({
2595
2568
  const nodes = _react.useMemo(() => buildPreviewNodes(messages), [messages]);
2596
2569
  const feedRef = _react.useRef(null);
2597
2570
  const { activeKey, bindNode } = usePreviewRail(nodes, feedRef);
2598
- const snapshotMs = preview?.scope === "history" && typeof preview?.snapshot?.createdAt === "string"
2599
- ? Date.parse(preview.snapshot.createdAt) : NaN;
2600
- const previewSourceId = preview?.scope === "history" ? preview?.snapshot?.snapshotId : preview?.session?.id;
2571
+ const previewSourceId = preview?.session?.id;
2601
2572
  _react.useEffect(() => {
2602
2573
  const previousFocus = returnFocus ?? document.activeElement;
2603
2574
  (closeRef.current ?? dialogRef.current)?.focus?.();
@@ -2655,7 +2626,6 @@ window.__ModuleLoader__.load({
2655
2626
  (0, jsx.jsxs)("div", { className: "dac-preview-heading", children: [
2656
2627
  (0, jsx.jsx)("strong", { id: "dac-preview-title", children: preview?.session?.title || t("chat.untitled") }),
2657
2628
  (0, jsx.jsx)("span", { children: t("preview.readOnly") }),
2658
- Number.isFinite(snapshotMs) && (0, jsx.jsx)("time", { dateTime: preview.snapshot.createdAt, children: `${t("preview.snapshotAt")}: ${formatDate(t, snapshotMs)}` })
2659
2629
  ] }),
2660
2630
  (0, jsx.jsx)("button", { ref: closeRef, type: "button", className: "dac-iconbtn", "aria-label": t("preview.close"), onClick: onCancel, children: (0, jsx.jsx)(IconClose, { size: 16 }) })
2661
2631
  ] }),
@@ -2692,7 +2662,7 @@ window.__ModuleLoader__.load({
2692
2662
  }, [menuOpen, onToggleMenu]);
2693
2663
  const ids = group.sessionIds;
2694
2664
  const restorableIds = group.items
2695
- .filter((row) => row.state !== "purge-pending" && row.restorable !== false)
2665
+ .filter((row) => row.state !== "purge-pending")
2696
2666
  .map((row) => row.sessionId);
2697
2667
  const groupBusy = ids.some((id) => busy[id] === true);
2698
2668
  return (0, jsx.jsxs)("section", { className: "dac-group dac-trash-group", children: [
@@ -2740,8 +2710,7 @@ window.__ModuleLoader__.load({
2740
2710
  collapsed !== true && (0, jsx.jsx)("div", { className: "dac-list", children: group.items.map((row) => {
2741
2711
  const title = row.title || t("chat.untitled");
2742
2712
  const pending = row.state === "purge-pending";
2743
- const legacy = row.sourceKind === "legacy-snapshot";
2744
- const restorable = !pending && row.restorable !== false;
2713
+ const restorable = !pending;
2745
2714
  const rowBusy = busy[row.sessionId] === true;
2746
2715
  const trashedMs = typeof row.trashedAt === "string" ? Date.parse(row.trashedAt) : NaN;
2747
2716
  const attachments = Number.isInteger(row.snapshotAttachmentCount) ? row.snapshotAttachmentCount : 0;
@@ -2759,7 +2728,7 @@ window.__ModuleLoader__.load({
2759
2728
  ] }),
2760
2729
  ] }),
2761
2730
  (0, jsx.jsxs)("div", { className: "dac-row-actions dac-trash-actions", children: [
2762
- (0, jsx.jsx)("button", { type: "button", className: "dac-iconbtn", "aria-label": previewOpenLabel(t, title), disabled: rowBusy || (legacy && !restorable), onClick: () => onPreview(row), children: (0, jsx.jsx)(IconEye, {}) }),
2731
+ (0, jsx.jsx)("button", { type: "button", className: "dac-iconbtn", "aria-label": previewOpenLabel(t, title), disabled: rowBusy, onClick: () => onPreview(row), children: (0, jsx.jsx)(IconEye, {}) }),
2763
2732
  (0, jsx.jsx)("button", { type: "button", className: "dac-iconbtn", "aria-label": t("trash.restore"), title: t("trash.restore"), "data-session-id": row.sessionId, disabled: !mutationsAvailable || !restorable || rowBusy, onClick: () => onRestore([row.sessionId]), children: (0, jsx.jsx)(IconRestore, {}) }),
2764
2733
  (0, jsx.jsx)("button", { type: "button", className: "dac-iconbtn dac-danger", "aria-label": t("trash.purge"), title: t("trash.purge"), "data-session-id": row.sessionId, disabled: !mutationsAvailable || pending || rowBusy, onClick: () => onPurge([row.sessionId]), children: (0, jsx.jsx)(IconTrash, {}) }),
2765
2734
  ] }),
@@ -3023,7 +2992,6 @@ window.__ModuleLoader__.load({
3023
2992
  const dialogRef = _react.useRef(null);
3024
2993
  const cancelRef = _react.useRef(null);
3025
2994
  const candidates = Array.isArray(preview?.candidates) ? preview.candidates : [];
3026
- const snapshotCandidates = candidates.filter((item) => item.action === "delete-snapshot");
3027
2995
  const trashCandidates = candidates.filter((item) => item.action === "purge-trash");
3028
2996
  const hasPurge = candidates.some((item) => selected.has(item.key) && item.action === "purge-trash");
3029
2997
  const rows = (items) => items.map((item) => (0, jsx.jsxs)("label", { className: `dac-retention-row${item.action === "purge-trash" ? " dac-danger" : ""}`, children: [
@@ -3051,7 +3019,7 @@ window.__ModuleLoader__.load({
3051
3019
  (0, jsx.jsx)("div", { className: "dac-confirm-body", children: t("retention.projected") }),
3052
3020
  hasPurge && (0, jsx.jsx)("div", { className: "dac-warn", role: "alert", children: t("retention.purgeWarning") }),
3053
3021
  candidates.length === 0 && (0, jsx.jsx)("div", { className: "dac-confirm-body", children: t("retention.empty") }),
3054
- candidates.length > 0 && (0, jsx.jsxs)("div", { className: "dac-retention-list", children: [snapshotCandidates.length > 0 && (0, jsx.jsxs)(jsx.Fragment, { children: [(0, jsx.jsx)("strong", { children: t("retention.snapshotCandidate") }), ...rows(snapshotCandidates)] }), trashCandidates.length > 0 && (0, jsx.jsxs)(jsx.Fragment, { children: [(0, jsx.jsx)("strong", { children: t("retention.trashCandidate") }), ...rows(trashCandidates)] })] }),
3022
+ candidates.length > 0 && (0, jsx.jsxs)("div", { className: "dac-retention-list", children: [trashCandidates.length > 0 && (0, jsx.jsxs)(jsx.Fragment, { children: [...rows(trashCandidates)] })] }),
3055
3023
  (0, jsx.jsxs)("div", { className: "dac-confirm-actions", children: [
3056
3024
  (0, jsx.jsx)("button", { ref: cancelRef, type: "button", className: "dac-btn", onClick: onCancel, children: t(candidates.length === 0 ? "retention.close" : "confirm.cancel") }),
3057
3025
  candidates.length > 0 && (0, jsx.jsx)("button", { type: "button", className: hasPurge ? "dac-btn-danger" : "dac-btn", disabled: busy || selected.size === 0, onClick: onApply, children: t("retention.apply") })
@@ -3861,19 +3829,6 @@ window.__ModuleLoader__.load({
3861
3829
  };
3862
3830
 
3863
3831
  const openPreview = async (session, scope = "archive") => {
3864
- if (scope === "trash" && session?.sourceKind === "legacy-snapshot") {
3865
- if (session.restorable === false || typeof session.legacySnapshotId !== "string") return;
3866
- await openHistoryPreview({
3867
- sessionId: session.sourceSessionId ?? session.sessionId,
3868
- title: session.title,
3869
- workspace: session.workspace ?? null,
3870
- }, {
3871
- snapshotId: session.legacySnapshotId,
3872
- createdAt: session.createdAt === null || session.createdAt === undefined
3873
- ? session.trashedAt : new Date(session.createdAt).toISOString(),
3874
- });
3875
- return;
3876
- }
3877
3832
  const request = beginPreviewRequest();
3878
3833
  previewReturnFocusRef.current = document.activeElement;
3879
3834
  setPreview({ status: "loading", session, scope, messages: [], total: 0, nextOffset: null });
@@ -3890,42 +3845,14 @@ window.__ModuleLoader__.load({
3890
3845
  }
3891
3846
  };
3892
3847
 
3893
- const openHistoryPreview = async (session, version) => {
3894
- const request = beginPreviewRequest();
3895
- const safeSession = { id: session.sessionId, title: session.title, workspace: session.workspace ?? null };
3896
- const snapshot = { snapshotId: version.snapshotId, sessionId: session.sessionId, createdAt: version.createdAt };
3897
- previewReturnFocusRef.current = document.activeElement;
3898
- setPreview({ status: "loading", session: safeSession, snapshot, scope: "history", messages: [], total: 0, nextOffset: null });
3899
- setPreviewBusy(true);
3900
- try {
3901
- const result = await fetchHistoryPreview(version.snapshotId, 0, 50, request.controller.signal);
3902
- if (!ownsPreviewRequest(request)) return;
3903
- setPreview({
3904
- ...result,
3905
- status: "ready",
3906
- scope: "history",
3907
- session: safeSession,
3908
- snapshot: result?.snapshot ?? snapshot,
3909
- messages: Array.isArray(result?.messages) ? result.messages : [],
3910
- });
3911
- } catch (error) {
3912
- if (!ownsPreviewRequest(request)) return;
3913
- setPreview({ status: "error", session: safeSession, snapshot, scope: "history", messages: [], total: 0, nextOffset: null, error: String(error.message ?? error) });
3914
- } finally {
3915
- if (ownsPreviewRequest(request)) setPreviewBusy(false);
3916
- }
3917
- };
3918
-
3919
3848
  const loadMorePreview = async () => {
3920
- if ((!preview?.session?.id && !preview?.snapshot?.snapshotId) || preview.nextOffset === null || previewBusy) return;
3849
+ if (!preview?.session?.id || preview.nextOffset === null || previewBusy) return;
3921
3850
  const sessionId = preview.session.id;
3922
3851
  const nextOffset = preview.nextOffset;
3923
3852
  const request = beginPreviewRequest();
3924
3853
  setPreviewBusy(true);
3925
3854
  try {
3926
- const result = preview.scope === "history"
3927
- ? await fetchHistoryPreview(preview.snapshot.snapshotId, nextOffset, 50, request.controller.signal)
3928
- : await fetchArchivePreview(sessionId, nextOffset, 50, request.controller.signal, preview.scope ?? "archive");
3855
+ const result = await fetchArchivePreview(sessionId, nextOffset, 50, request.controller.signal, preview.scope ?? "archive");
3929
3856
  if (!ownsPreviewRequest(request)) return;
3930
3857
  setPreview((current) => current ? {
3931
3858
  ...current,
@@ -4433,7 +4360,7 @@ window.__ModuleLoader__.load({
4433
4360
  //#endregion
4434
4361
 
4435
4362
  exports.SETTINGS_NS = SETTINGS_NS;
4436
- exports.__test = { NotePreview, formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, uniqueSessionIds, createArchiveNoticeController, installArchiveNoticeInterceptor, openArchiveSettings, ArchiveNoticeOverlay, WorkspaceArchiveChooserDialog, WorkspaceArchiveDialog, defaultRetentionSelection, filterLineageForest, SnapshotInsightRows, LineageTreeNodes, StorageRetentionPanel, ArchiveRelationshipsPanel, groupTrashSessions, trashStatusLabel, markArchiveDialog, submitExport, submitImportFile, fetchTrash, fetchHistoryPreview, fetchHistoryImage, fetchInsights, saveRetentionPolicy, previewRetention, applyRetention, fetchLineage, restoreTrash, purgeTrash, emptyTrash, fetchArchivePreview, fetchArchiveImage, fetchArchiveSearch, fetchWorkspaceArchiveWorkspaces, previewWorkspaceArchive, applyWorkspaceArchive, buildPreviewNodes, previewCopyText, groupPreviewSegments, resolvePreviewPrimitives, PreviewMarkdown, PreviewReasoning, PreviewImage, editIconSpec: EDIT_ICON_SPEC };
4363
+ exports.__test = { NotePreview, formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, uniqueSessionIds, createArchiveNoticeController, installArchiveNoticeInterceptor, openArchiveSettings, ArchiveNoticeOverlay, WorkspaceArchiveChooserDialog, WorkspaceArchiveDialog, defaultRetentionSelection, filterLineageForest, SnapshotInsightRows, LineageTreeNodes, StorageRetentionPanel, ArchiveRelationshipsPanel, groupTrashSessions, trashStatusLabel, markArchiveDialog, submitExport, submitImportFile, fetchTrash, fetchInsights, saveRetentionPolicy, previewRetention, applyRetention, fetchLineage, restoreTrash, purgeTrash, emptyTrash, fetchArchivePreview, fetchArchiveImage, fetchArchiveSearch, fetchWorkspaceArchiveWorkspaces, previewWorkspaceArchive, applyWorkspaceArchive, buildPreviewNodes, previewCopyText, groupPreviewSegments, resolvePreviewPrimitives, PreviewMarkdown, PreviewReasoning, PreviewImage, editIconSpec: EDIT_ICON_SPEC };
4437
4364
  exports.apply = apply;
4438
4365
  exports.inject = inject;
4439
4366
  return module.exports;