dsh-archived-chats 1.1.2 → 1.2.0

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
@@ -27,7 +27,7 @@
27
27
 
28
28
  </div>
29
29
 
30
- Session Archive gives DeepSeek Harness a first-class home for chats that disappear from the sidebar after archive. Browse every archived conversation by workspace, search its full content, inspect validated local history, and recover or remove it through explicit, reversible workflows.
30
+ Session Archive gives DeepSeek Harness a first-class home for chats that disappear from the sidebar after archive. Browse every archived conversation by workspace, search its full content, recover deleted chats from the Recycle Bin, and manage them through explicit, reversible workflows.
31
31
 
32
32
  > Formerly **Archived Chats**, now **Session Archive / 会话档案**. The package name, repository, install command, and local data location are unchanged; existing users need no data migration.
33
33
 
@@ -46,7 +46,7 @@ dsh plugin --profile web update dsh-archived-chats
46
46
  ```
47
47
 
48
48
  <p align="center">
49
- <a href="assets/screenshots/preview-03.png"><img src="assets/screenshots/preview-03.png" width="49%" alt="Native read-only History preview with snapshot time and a synthetic stored image"></a>
49
+ <a href="assets/screenshots/preview-03.png"><img src="assets/screenshots/preview-03.png" width="49%" alt="Native read-only legacy-data preview with snapshot time and a synthetic stored image"></a>
50
50
  <a href="assets/screenshots/preview-07.png"><img src="assets/screenshots/preview-07.png" width="49%" alt="Storage and Retention with session directories, protection snapshots, and policy controls"></a>
51
51
  </p>
52
52
 
@@ -57,15 +57,17 @@ dsh plugin --profile web update dsh-archived-chats
57
57
  | **Browse and search** | Workspace-grouped archive browsing, full-text search across messages and tool results, filters, sorting, tags, and notes. |
58
58
  | **Archive a workspace** | From **Settings → Session Archive**, choose a workspace and open one confirmation for every eligible chat in it. Empty new-session windows are excluded; the workspace and its directory stay unchanged. |
59
59
  | **Read-only preview** | Native conversation layout for Markdown, reasoning, tool activity, JSON, code, and available stored images, with responsive turn navigation. |
60
- | **Local History** | Validated versions captured after archive, read-only snapshot preview, confirmed deletion, clear-history, and **Restore as copy** without overwriting the source. |
60
+ | **Existing data** | Prior snapshots remain accessible under **Storage & Retention → Legacy data** for read-only preview, recovery as an archived copy, or confirmed deletion. Archiving no longer creates versions. |
61
61
  | **Backup and restore** | JSON + Markdown ZIP export with preview-first, conflict-safe import. Existing session IDs are never overwritten. |
62
62
  | **Recoverable deletion** | Snapshot-protected Recycle Bin with immediate Undo, two-level restore, separately confirmed permanent deletion, and an optional direct permanent-delete action for users who do not need recovery. |
63
- | **Storage and relationships** | Separate storage accounting, preview-first retention policies, and read-only Origins & Branches for forks and subagent trees. |
63
+ | **Storage and relationships** | Separate storage accounting, preview-first Recycle Bin age policies, and read-only Origins & Branches for forks and subagent trees. |
64
+
65
+ The four main views are **Archived**, **Recycle Bin**, **Storage & Retention**, and **Origins & Branches**. Existing snapshots are preserved during upgrades; the separate History tab and archive-time capture have been retired.
64
66
 
65
67
  ## Safety by design
66
68
 
67
69
  - **Local only:** plugin metadata, recycle records, policies, and validated snapshots stay under `$DSH_HOME/plugin-data/archived-chats/`. Nothing is uploaded or cloud-synced.
68
- - **No silent overwrite:** imports and History restores create or select non-conflicting IDs; they never replace an existing session.
70
+ - **No silent overwrite:** imports and legacy-data recovery create or select non-conflicting IDs; they never replace an existing session.
69
71
  - **Deletion stays explicit:** ordinary removal enters the Recycle Bin after snapshot protection. Physical removal is available only through confirmed permanent-purge actions.
70
72
  - **No automatic cleanup:** retention policies are saved separately from execution. Every cleanup starts with a short-lived preview and explicit selection.
71
73
  - **Confirmed workspace archive:** the plugin prepares the exact set in the background, then shows one confirmation with the workspace and chat count. Only sessions whose inspected log contains a real `turn/start` are eligible; empty new-session windows and sessions whose content cannot be confirmed are skipped. Its five-minute, single-use credential excludes chats added later; a running chat is skipped, never stopped or moved.
@@ -77,14 +79,14 @@ Features activate from the public capabilities exposed by the DeepSeek Harness H
77
79
 
78
80
  | Host capability | Plugin behavior |
79
81
  | --- | --- |
80
- | Archive and session reads | Browsing, search, preview, History inventory, storage accounting, and lineage. |
82
+ | Archive and session reads | Browsing, search, preview, legacy-data inventory, storage accounting, and lineage. |
81
83
  | `settings.section` + public `archiveSession` | The plugin-owned settings page provides the workspace chooser and one-confirmation archive flow without requiring a workspace-menu extension slot. Without archive capability, preparation returns `workspace-archive-unsupported` and makes no change. |
82
84
  | Attachment reads | Stored images appear in conversation and snapshot previews; without it, text remains readable. |
83
85
  | Session-scoped log location | Recycle Bin permanent deletion uses the persistence provider's public `locate(meta)` capability. Providers without a session-scoped location remain unsupported; failed operations retain their rows and display the reason. |
84
- | Public session writer | ZIP import, **Restore as copy**, and snapshot fallback when an original is missing all write through the Host's public `create` / `append` / `locate` capability, or a dedicated restore entry point where one exists. |
86
+ | Public session writer | ZIP import, legacy-data recovery, and snapshot fallback when an original is missing all write through the Host's public `create` / `append` / `locate` capability, or a dedicated restore entry point where one exists. |
85
87
  | Missing write capability | The operation returns `restore-unsupported` without writing or overwriting data. |
86
88
 
87
- Back up `$DSH_HOME/plugin-data/archived-chats/` before downgrading to a release that does not display History or understand recycle snapshots.
89
+ Back up `$DSH_HOME/plugin-data/archived-chats/` before downgrading to a release that does not display legacy data or understand recycle snapshots.
88
90
 
89
91
  ## Demo preview
90
92
 
@@ -95,15 +97,15 @@ The eight fixed screenshots below come from an isolated Simplified Chinese light
95
97
  <br>
96
98
  <table>
97
99
  <tr>
98
- <td><img src="assets/screenshots/preview-01.png" alt="Session Archive overview with five management views"><br><sub>Archive overview</sub></td>
100
+ <td><img src="assets/screenshots/preview-01.png" alt="Session Archive overview with four management views"><br><sub>Archive overview</sub></td>
99
101
  <td><img src="assets/screenshots/preview-02.png" alt="Full-text search, filters, tags, and readable hit excerpts"><br><sub>Full-text search</sub></td>
100
102
  </tr>
101
103
  <tr>
102
- <td><img src="assets/screenshots/preview-03.png" alt="Native read-only History preview with a stored image"><br><sub>Read-only preview</sub></td>
103
- <td><img src="assets/screenshots/preview-04.png" alt="History timeline with restore-as-copy and deletion actions"><br><sub>History timeline</sub></td>
104
+ <td><img src="assets/screenshots/preview-03.png" alt="Native read-only legacy-data preview with a stored image"><br><sub>Read-only preview</sub></td>
105
+ <td><img src="assets/screenshots/preview-04.png" alt="Legacy data with recovery and deletion actions"><br><sub>Legacy data</sub></td>
104
106
  </tr>
105
107
  <tr>
106
- <td><img src="assets/screenshots/preview-05.png" alt="Irreversible confirmation before clearing ordinary History"><br><sub>Clear History confirmation</sub></td>
108
+ <td><img src="assets/screenshots/preview-05.png" alt="Irreversible confirmation before clearing legacy data"><br><sub>Clear legacy data confirmation</sub></td>
107
109
  <td><img src="assets/screenshots/preview-06.png" alt="Recycle Bin protection snapshot, restore, and permanent deletion"><br><sub>Recycle Bin</sub></td>
108
110
  </tr>
109
111
  <tr>
@@ -133,7 +135,7 @@ Session Archive is actively maintained. The latest stable npm release receives f
133
135
  npm test
134
136
  ```
135
137
 
136
- The suite covers Host and browser behavior, export/import, History, Recycle Bin, retention, search, responsive layout, public types, package contents, and repository hygiene. It uses isolated temporary data and never reads real sessions.
138
+ The suite covers Host and browser behavior, export/import, legacy-data recovery, Recycle Bin, retention, search, responsive layout, public types, package contents, and repository hygiene. It uses isolated temporary data and never reads real sessions.
137
139
 
138
140
  ## Uninstall
139
141
 
package/README.zh-CN.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  </div>
28
28
 
29
- 会话档案为 DeepSeek Harness 中归档后从侧边栏消失的聊天提供统一入口。你可以按工作区浏览全部归档聊天、全文搜索对话、查看已验证的本地历史,并通过明确且可恢复的流程恢复或删除它们。
29
+ 会话档案为 DeepSeek Harness 中归档后从侧边栏消失的聊天提供统一入口。你可以按工作区浏览全部归档聊天、全文搜索对话、从回收站恢复已删除的聊天,并通过明确且可恢复的流程恢复或删除它们。
30
30
 
31
31
  > 原「已归档的聊天」现已更名为「会话档案 / Session Archive」。包名、仓库、安装命令和本地数据位置均未改变,现有用户无需迁移数据。
32
32
 
@@ -45,7 +45,7 @@ dsh plugin --profile web update dsh-archived-chats
45
45
  ```
46
46
 
47
47
  <p align="center">
48
- <a href="assets/screenshots/preview-03.png"><img src="assets/screenshots/preview-03.png" width="49%" alt="带历史快照时间和合成图片的原生只读预览"></a>
48
+ <a href="assets/screenshots/preview-03.png"><img src="assets/screenshots/preview-03.png" width="49%" alt="带旧版快照时间和合成图片的原生只读预览"></a>
49
49
  <a href="assets/screenshots/preview-07.png"><img src="assets/screenshots/preview-07.png" width="49%" alt="空间与策略中的会话目录、保护快照和保留策略"></a>
50
50
  </p>
51
51
 
@@ -56,15 +56,17 @@ dsh plugin --profile web update dsh-archived-chats
56
56
  | **浏览与搜索** | 按工作区浏览归档聊天,全文搜索消息和工具结果,并支持筛选、排序、标签与备注。 |
57
57
  | **归档整个工作区** | 从 **设置 → 会话档案** 打开工作区选择器,再用一次确认归档其中全部符合条件的聊天;空白的新会话窗口不会计入,工作区及其目录保持不变。 |
58
58
  | **原生只读预览** | 以原生对话布局展示 Markdown、思考过程、工具活动、JSON、代码和可用的已存储图片,并提供响应式轮次导航。 |
59
- | **本地历史版本** | 归档后保存已验证版本,支持只读快照预览、确认删除、清空历史,以及不覆盖来源的 **恢复为副本**。 |
59
+ | **旧版数据** | 已有快照保留在 **空间与策略 → 旧版数据**,支持只读预览、恢复为归档副本或确认删除;归档不再生成版本。 |
60
60
  | **备份与恢复** | 导出 JSON + Markdown ZIP,并通过预览优先、冲突安全的流程导入;已有会话 ID 永不覆盖。 |
61
61
  | **可恢复删除** | 带保护快照的回收站支持立即撤销、两级恢复,以及单独确认的永久删除。 |
62
- | **空间与关系** | 空间分账、预览优先的保留策略,以及用于分叉和子代理树的只读「来源与分支」。 |
62
+ | **空间与关系** | 空间分账、预览优先的回收站保留天数策略,以及用于分叉和子代理树的只读「来源与分支」。 |
63
+
64
+ 四个主视图为 **已归档**、**回收站**、**空间与策略** 和 **来源与分支**。升级会保留已有快照;独立历史版本标签与归档时自动抓取已取消。
63
65
 
64
66
  ## 安全设计
65
67
 
66
68
  - **数据只在本机:** 插件元数据、回收记录、策略和已验证快照均保存在 `$DSH_HOME/plugin-data/archived-chats/`,不会上传或云同步。
67
- - **不静默覆盖:** 导入和历史恢复只创建或选择无冲突 ID,绝不覆盖已有会话。
69
+ - **不静默覆盖:** 导入和旧版数据恢复只创建或选择无冲突 ID,绝不覆盖已有会话。
68
70
  - **删除必须明确:** 普通移除会在快照保护后进入回收站;只有经过确认的永久删除操作才会物理清除。
69
71
  - **不自动清理:** 保留策略的保存与执行分离,每次清理都从短效预览和明确选择开始。
70
72
  - **确认工作区归档:** 插件在后台准备精确集合,再显示一次包含工作区与会话数量的确认。只有检查到真实 `turn/start` 的会话才符合条件;空白的新会话窗口及无法确认内容的会话都会跳过。5 分钟有效、只能使用一次的凭据不会纳入之后新建的聊天;运行中的聊天只会跳过,绝不停止或移动。
@@ -76,14 +78,14 @@ dsh plugin --profile web update dsh-archived-chats
76
78
 
77
79
  | Host 能力 | 插件行为 |
78
80
  | --- | --- |
79
- | 归档与会话读取 | 浏览、搜索、预览、历史清单、空间分账和会话血缘。 |
81
+ | 归档与会话读取 | 浏览、搜索、预览、旧版数据清单、空间分账和会话血缘。 |
80
82
  | `settings.section` + 公开 `archiveSession` | 插件自己的设置页提供工作区选择器和一次确认归档流程,不依赖工作区菜单扩展 slot;缺少归档能力时,准备请求返回 `workspace-archive-unsupported`,且不作任何修改。 |
81
83
  | 附件读取 | 对话和快照预览可显示已存储图片;缺少时文本内容仍可阅读。 |
82
84
  | 会话独立日志位置 | 回收站永久删除使用持久化后端公开的 `locate(meta)` 能力。后端不提供会话独立位置时不支持永久删除;失败的条目保留在列表中并显示具体原因。 |
83
- | 公开会话 writer | ZIP 导入、**恢复为副本** 和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
85
+ | 公开会话 writer | ZIP 导入、旧版数据恢复 和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
84
86
  | 缺少写入能力 | 操作返回 `restore-unsupported`,不会写入或覆盖数据。 |
85
87
 
86
- 降级到不显示历史版本或不识别回收快照的版本前,请备份 `$DSH_HOME/plugin-data/archived-chats/`。
88
+ 降级到不显示旧版数据或不识别回收快照的版本前,请备份 `$DSH_HOME/plugin-data/archived-chats/`。
87
89
 
88
90
  ## 演示预览
89
91
 
@@ -94,15 +96,15 @@ dsh plugin --profile web update dsh-archived-chats
94
96
  <br>
95
97
  <table>
96
98
  <tr>
97
- <td><img src="assets/screenshots/preview-01.png" alt="会话档案总览和五个管理视图"><br><sub>归档总览</sub></td>
99
+ <td><img src="assets/screenshots/preview-01.png" alt="会话档案总览和四个管理视图"><br><sub>归档总览</sub></td>
98
100
  <td><img src="assets/screenshots/preview-02.png" alt="全文搜索、筛选、标签与命中摘要"><br><sub>全文搜索</sub></td>
99
101
  </tr>
100
102
  <tr>
101
- <td><img src="assets/screenshots/preview-03.png" alt="带已存储图片的原生只读历史预览"><br><sub>原生只读预览</sub></td>
102
- <td><img src="assets/screenshots/preview-04.png" alt="带恢复为副本与删除操作的历史时间线"><br><sub>历史时间线</sub></td>
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>
103
105
  </tr>
104
106
  <tr>
105
- <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>
106
108
  <td><img src="assets/screenshots/preview-06.png" alt="保护快照、恢复和永久删除"><br><sub>回收站</sub></td>
107
109
  </tr>
108
110
  <tr>
@@ -132,7 +134,7 @@ Session Archive 目前处于积极维护状态。最新 npm 稳定版会接收
132
134
  npm test
133
135
  ```
134
136
 
135
- 测试覆盖 Host 与浏览器行为、导出导入、历史版本、回收站、保留策略、全文搜索、响应式布局、公开类型、包内容和仓库卫生。测试只使用隔离临时数据,不读取真实会话。
137
+ 测试覆盖 Host 与浏览器行为、导出导入、旧版数据恢复、回收站、保留策略、全文搜索、响应式布局、公开类型、包内容和仓库卫生。测试只使用隔离临时数据,不读取真实会话。
136
138
 
137
139
  ## 卸载
138
140
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -10,7 +10,7 @@ The plugin has a Host service half and a browser client half:
10
10
 
11
11
  - The Host service in lib/index.js runs inside the DSH Web host, reads the workspace registry and session persistence, and exposes local HTTP routes.
12
12
  - The browser client in lib/client.js registers the Session Archive settings.section and renders state and actions.
13
- - Pure domain logic lives in 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/lineage.js, and lib/workspace-bulk-archive.js. lib/persistence-compat.js narrows the current Host's handle-based reads to a private read-only view; lib/history.js owns capture, safe inventory, and preview authorization; lib/history-restore.js owns single-use restore-as-copy transactions. lib/trash.js owns the recycle catalog, lib/snapshot.js owns verified snapshots, and lib/recycle.js composes recycle lifecycle operations.
13
+ - Pure domain logic lives in 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/lineage.js, and lib/workspace-bulk-archive.js. lib/persistence-compat.js narrows the current Host's handle-based reads to a private read-only view; lib/history.js owns legacy-data inventory and preview authorization; lib/history-restore.js owns single-use restore-as-copy transactions. lib/trash.js owns the recycle catalog, lib/snapshot.js owns verified snapshots, and lib/recycle.js composes recycle lifecycle operations.
14
14
 
15
15
  The browser never reads session files directly. All reads and writes go through Host routes.
16
16
 
@@ -54,9 +54,9 @@ POST /plugins/dsh-archived-chats/delete
54
54
  POST /plugins/dsh-archived-chats/delete-all
55
55
  ~~~
56
56
 
57
- Every mutating route, plus preview, preview/image, search, history/preview, and history/preview/image, requires the `x-dsh-archived-chats: 1` header. `GET /history` returns only bounded safe inventory. History images require both the snapshot identity and the complete projected descriptor to match.
57
+ Every mutating route, plus preview, preview/image, search, history/preview, and history/preview/image, requires the `x-dsh-archived-chats: 1` header. `GET /history` returns only bounded safe inventory. Legacy-data images require both the snapshot identity and the complete projected descriptor to match.
58
58
 
59
- Workspace archive lists only safe workspace summaries. Preview accepts exactly one workspace ID and creates a five-minute, single-use token/nonce for at most 2,000 ordered eligible IDs. Apply accepts only that token and nonce, never caller-selected IDs. Eligibility requires unarchived membership in the existing workspace, no non-idle Host agent, and an inspected live or persisted event log containing `turn/start`; an empty new-session window is classified as `session-empty`, while an inspection failure is `session-unavailable`, and both fail closed. On Hosts without agent status, a loaded session is conservatively considered live. Candidate inspection is bounded to eight concurrent reads. Each item revalidates membership, archive state, agent status, and conversation content inside the shared lifecycle queue, then invokes the public `workspaceRegistry.archiveSession()` receiver-bound to the registry. New chats after preview are excluded; an item that became live, empty, unavailable, archived, or detached is reported and skipped. Successful archives attempt History capture under the held lifecycle lock; a capture failure is reported without rolling back the archive, and later items continue. This feature changes neither workspace membership nor a workspace path or directory, and never moves a chat across workspaces. A Host without public `archiveSession` returns `workspace-archive-unsupported` without mutation.
59
+ Workspace archive lists only safe workspace summaries. Preview accepts exactly one workspace ID and creates a five-minute, single-use token/nonce for at most 2,000 ordered eligible IDs. Apply accepts only that token and nonce, never caller-selected IDs. Eligibility requires unarchived membership in the existing workspace, no non-idle Host agent, and an inspected live or persisted event log containing `turn/start`; an empty new-session window is classified as `session-empty`, while an inspection failure is `session-unavailable`, and both fail closed. On Hosts without agent status, a loaded session is conservatively considered live. Candidate inspection is bounded to eight concurrent reads. Each item revalidates membership, archive state, agent status, and conversation content inside the shared lifecycle queue, then invokes the public `workspaceRegistry.archiveSession()` receiver-bound to the registry. New chats after preview are excluded; an item that became live, empty, unavailable, archived, or detached is reported and skipped. Successful archives do not capture snapshots. This feature changes neither workspace membership nor a workspace path or directory, and never moves a chat across workspaces. A Host without public `archiveSession` returns `workspace-archive-unsupported` without mutation.
60
60
 
61
61
  ## State and local data
62
62
 
@@ -73,7 +73,7 @@ Metadata and recycle catalogs are versioned. Writes serialize and atomically rep
73
73
 
74
74
  The stats route measures session directories with concurrency four, skips symbolic links, and caches results for 30 seconds. A measurement failure marks only that row unavailable; list and mutation actions continue. Delete invalidates the affected cache row.
75
75
 
76
- Insights joins session measurement with a stream-verified snapshot inventory and counts repeated snapshot attachments only from validated SHA-256 descriptors. The browser keeps totals in summary cards and presents session/snapshot inventories only in bounded searchable dialogs. retention.json uses an exact version-one schema; saving never runs cleanup. Preview issues a five-minute single-use token/nonce and apply revalidates inside the lifecycle queue; recycle candidates still delegate to recycle purge. Lineage uses only durable parentSession edges, never rewrites headers, and resolves titles for at most 100 untitled active source nodes already included in the focused tree. Its 5,000-node cap bounds the PROJECTED graph, not the Host's store: `focusIds` narrows the output to archived and recycled chats plus their explaining context, so a store of 100,000 sessions with 25 archived projects 25 nodes. Header fields are coerced per node rather than validated whole-graph — an unanticipated `origin` value, an absent `createdAt`, a changed numeric type each degrade that node to a value the public LineageNode already allows, and only an unusable identity drops a row. A projection that rejected the whole graph for one unrecognized header let a Host-side change disable the panel with no plugin change at all; malformed workspace and recycle entries are skipped for the same reason.
76
+ Insights joins session measurement with a stream-verified snapshot inventory and counts repeated snapshot attachments only from validated SHA-256 descriptors. The browser keeps totals in summary cards and presents session/snapshot inventories only in bounded searchable dialogs. retention.json retains a compatible version-one schema. Only Recycle Bin age contributes cleanup candidates; legacy snapshot-count, age, and quota fields do not schedule snapshot deletion. Saving never runs cleanup. Preview issues a five-minute single-use token/nonce and apply revalidates inside the lifecycle queue; recycle candidates still delegate to recycle purge. Lineage uses only durable parentSession edges, never rewrites headers, and resolves titles for at most 100 untitled active source nodes already included in the focused tree. Its 5,000-node cap bounds the PROJECTED graph, not the Host's store: `focusIds` narrows the output to archived and recycled chats plus their explaining context, so a store of 100,000 sessions with 25 archived projects 25 nodes. Header fields are coerced per node rather than validated whole-graph — an unanticipated `origin` value, an absent `createdAt`, a changed numeric type each degrade that node to a value the public LineageNode already allows, and only an unusable identity drops a row. A projection that rejected the whole graph for one unrecognized header let a Host-side change disable the panel with no plugin change at all; malformed workspace and recycle entries are skipped for the same reason.
77
77
 
78
78
  ## Preview and full-text search
79
79
 
@@ -83,15 +83,15 @@ The preview/image authorization sequence is fixed: first require POST and `x-dsh
83
83
 
84
84
  Cross-session persistence inspection is limited to four concurrent reads, stops scheduling batches once the hit limit is satisfied, and aborts an older browser request when a newer search starts. A broken session is reported in `skipped` while other hits still succeed. Canonical projection limits each segment to 256 Ki code points, each message to 1 Mi code points and 1,000 segments, and each session to 10,000 projected messages; unknown structured values are bounded by depth, node, and character budgets before stringify. A 30-second TTL, 64-session LRU, and per-session cache cap keep bounded projections resident. Unarchive, delete, and restore invalidate affected cache entries.
85
85
 
86
- ## History versions and restore-as-copy
86
+ ## Legacy-data compatibility and recovery
87
87
 
88
- `history/capture` is called only after the browser wrapper around public `workspaces.archiveSession` succeeds. The Host enters the lifecycle queue shared with recycle and retention, rechecks archive ownership and recycle state, requires a stable Host revision for a still-live session, and reuses a healthy snapshot for the same non-null revision. The plugin does not scan unrelated active chats and performs no startup, scheduled, or background capture.
88
+ Archiving no longer calls `history/capture`; that compatibility route refuses new History captures. Upgrades preserve existing snapshots. Existing inventory, preview, restore, and delete routes serve **Storage & Retention Legacy data**. Recycle Bin operations still capture protection snapshots required for recovery.
89
89
 
90
90
  `history.js` groups published snapshots as `archived`, `recycled`, or `history-only`, inspects no more than 5,000 snapshot directories, shares one in-flight request, and caches completed inventory for 30 seconds. Inventory contains only safe title/workspace title, timestamps, sizes, attachment counts, and protection state; degraded entries expose only snapshot ID and a stable code. Paginated preview and image reads revalidate snapshot identity, digests, and complete descriptors without returning paths or raw records.
91
91
 
92
92
  `history-restore.js` fully validates the snapshot, asks the Host for a new session ID, and issues a five-minute single-use token/nonce. Confirmation consumes the credential before writes and rechecks the manifest, then creates persistence, rewrites session/attachment identities, appends events, restores workspace and metadata, and commits archive registry state last. Failures reverse plugin-controlled steps. The source session and snapshot never change, and the plugin makes no claim that Host-global attachment objects were deleted.
93
93
 
94
- Single-version deletion and **Clear history versions** both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed. Both reject a snapshot a recycle record still names, degraded or not — that record is its last claim. Both otherwise accept a degraded snapshot: it can no longer be previewed or restored from, but its bytes are still on disk and no other surface can reclaim them (retention plans only healthy snapshots), so refusing here would leak the store permanently. Deletion physically removes the plugin snapshot and its attachment copies, while the original chat and other versions remain unchanged.
94
+ Legacy-data item deletion and clear-all both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed. Both reject a snapshot a recycle record still names, degraded or not — that record is its last claim. Both otherwise accept a degraded snapshot: it can no longer be previewed or restored from, but its bytes are still on disk and no other surface can reclaim them (retention now plans only Recycle Bin purges), so refusing here would leak the store permanently. Deletion physically removes the plugin snapshot and its attachment copies, while the original chat and other versions remain unchanged.
95
95
 
96
96
  ## Export flow
97
97
 
@@ -118,19 +118,19 @@ import/inspect accepts only version-one ZIPs produced by this plugin. The Host s
118
118
  5. restore.js uses a feature-detected adapter to write sessions, metadata, and archive state.
119
119
  6. Any failure rolls back staged data and never overwrites an existing session.
120
120
 
121
- The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import resolves a session writer by capability: a dedicated Host restore entry point when one exists, otherwise the ordinary `create` / `append` / `locate` surface — the same capability History restore-as-copy writes through, so import works wherever that works. The append writer carries its own session-scoped rollback (it confirms the located directory is the session's own before creating anything), so a separate removal capability is required only for a dedicated restore entry point. Archive and metadata write capabilities are still required. The staged id does not exist yet, so a session reader that fails closed on unknown ids is the expected answer to the capability probe and never aborts the restore. Workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
121
+ The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import resolves a session writer by capability: a dedicated Host restore entry point when one exists, otherwise the ordinary `create` / `append` / `locate` surface — the same capability legacy-data recovery writes through, so import works wherever that works. The append writer carries its own session-scoped rollback (it confirms the located directory is the session's own before creating anything), so a separate removal capability is required only for a dedicated restore entry point. Archive and metadata write capabilities are still required. The staged id does not exist yet, so a session reader that fails closed on unknown ids is the expected answer to the capability probe and never aborts the restore. Workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
122
122
 
123
123
  ## Recycle and protection-snapshot lifecycle
124
124
 
125
125
  `trash.json` permits only `trashed`, `purge-pending`, and `degraded`. Legal transitions are `missing -> trashed`, `trashed/degraded -> purge-pending`, and removal of an existing state after a committed transaction. A `purge-pending` record cannot restore.
126
126
 
127
- Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync. Windows cannot atomically replace a file or remove a directory entry while another handle is open on it — an indexer or antivirus scan is enough — so replaces and recursive removals retry the transient `EPERM` / `EACCES` / `EBUSY` codes there, bounded, and only there: on POSIX the same codes are permanent conditions and retrying would only delay the same failure. Snapshot publication also treats a rename refused onto an existing directory as a conflict after probing the destination, because Windows reports that as `EPERM` rather than `EEXIST`. Path containment is tested with the platform separator and rejects an absolute answer, so a Windows `..\` escape or a different drive letter cannot read as inside the root.
127
+ Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; retained snapshots from restore/recycle cycles remain available under Legacy data until explicit deletion or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync. Windows cannot atomically replace a file or remove a directory entry while another handle is open on it — an indexer or antivirus scan is enough — so replaces and recursive removals retry the transient `EPERM` / `EACCES` / `EBUSY` codes there, bounded, and only there: on POSIX the same codes are permanent conditions and retrying would only delay the same failure. Snapshot publication also treats a rename refused onto an existing directory as a conflict after probing the destination, because Windows reports that as `EPERM` rather than `EEXIST`. Path containment is tested with the platform separator and rejects an absolute answer, so a Windows `..\` escape or a different drive letter cannot read as inside the root.
128
128
 
129
129
  Move ordering is: validate archive ownership → dispose or park a live session → capture and verify snapshot → recheck ownership → atomically commit `trashed` → invalidate caches. Ordinary move never removes the persistence artifact.
130
130
 
131
131
  Archived sessions also expose an explicit direct permanent-delete action. It uses the same session-scoped physical-delete checks and crash-recovery bracket as Recycle Bin purge, but intentionally skips snapshot creation and recycle storage after a separate irreversible confirmation. Ordinary delete remains recoverable by default.
132
132
 
133
- Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the recycle record, without rewriting persistence; the snapshot remains history. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.
133
+ Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the recycle record, without rewriting persistence; the snapshot remains available under Legacy data. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.
134
134
 
135
135
  Permanent purge persists `purge-pending` before physical writes, then removes every snapshot for that source, then the original session, and finally the recycle record. The session delete is deliberately last: a failure before it leaves the original intact and the record completable, rather than a `purge-pending` record whose session is already gone and which can therefore neither restore nor complete. The snapshot sweep attributes each published snapshot by manifest identity so a snapshot that fails validation is still removed when it belongs to this session, and an unrelated unverifiable snapshot is skipped instead of aborting the sweep — corruption elsewhere in the store must never make a purge impossible. The recycle record also names its own snapshot id, covering one damaged past attribution. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Physical deletion additionally requires the located artifact to sit in a directory named for the session itself, so a backend layout that shares one parent between sessions can never have that parent removed. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
136
136
 
@@ -138,13 +138,13 @@ Permanent purge persists `purge-pending` before physical writes, then removes ev
138
138
 
139
139
  client.js registers an order-30 `settings.section` plus a `shell.overlay`, and uses public Host archive services and design tokens. Workspace archive UI state stays inside the plugin-owned settings section and requires no workspace-action slot or shared client store. The page state includes:
140
140
 
141
- - A frame-wide archive success notice in `shell.overlay`: during its effect lifetime the plugin wraps public `workspaces.archiveSession` and starts history capture only after the original succeeds. Capture pauses the three-second dismissal; success resumes it, while failure retains retry-save without rolling back archive. View and Undo remain available.
142
- - An **Archive workspace chats** action in **Settings → Session Archive**: it opens a workspace chooser, then prepares the selected workspace and shows one confirmation with the exact eligible count, Archived destination, and only a nonzero live-skip count, with no visible session preview. Full success refreshes consumers and closes; any skipped, failed, or snapshot-failed outcome retains the per-item result until dismissed.
141
+ - A frame-wide archive success notice in `shell.overlay`: during its effect lifetime the plugin wraps public `workspaces.archiveSession` and reports success only after the original succeeds. View and Undo remain available, with a three-second dismissal. No history capture is requested.
142
+ - An **Archive workspace chats** action in **Settings → Session Archive**: it opens a workspace chooser, then prepares the selected workspace and shows one confirmation with the exact eligible count, Archived destination, and only a nonzero live-skip count, with no visible session preview. Full success refreshes consumers and closes; any skipped or failed outcome retains the per-item result until dismissed.
143
143
  - Archived sessions and workspace groups.
144
144
  - Search, type/project/tag filters, and sorting.
145
145
  - Tag and note editor.
146
146
  - Selected-item export, unarchive, and move to Recycle Bin.
147
- - Archived, History, Recycle Bin, Storage & Retention, and Origins & Branches tabs. History requests safe inventory only on first activation and starts with groups collapsed. Snapshot preview reuses the conversation dialog with a visible snapshot timestamp. Restore confirmation focuses Cancel first and never places token/nonce in the render tree. Storage and relationship views retain on-demand loads, bounded dialogs, and read-only relationship projection.
147
+ - Four tabs: Archived, Recycle Bin, Storage & Retention, and Origins & Branches. Storage exposes Legacy data on demand for prior snapshot preview, recovery as a new archived copy, and confirmed deletion. Restore confirmation focuses Cancel first and never places token/nonce in the render tree. Storage and relationship views retain bounded dialogs and read-only relationship projection.
148
148
  - Import preview, disabled conflicts, and restore results.
149
149
  - Responsive settings-page markers and sidebar refresh injection.
150
150
 
@@ -159,7 +159,7 @@ The browser never mutates files directly. After an operation, the Host response
159
159
  ## Security and failure policy
160
160
 
161
161
  - All state-changing routes require POST and the guard header.
162
- - History responses exclude workspace/snapshot/attachment paths, raw events, notes, and confirmation tokens; logs contain only IDs and stable codes.
162
+ - Legacy-data responses exclude workspace/snapshot/attachment paths, raw events, notes, and confirmation tokens; logs contain only IDs and stable codes.
163
163
  - Workspace archive responses expose only safe workspace IDs/titles, eligible counts, session IDs/titles/timestamps in a confirmed preview, and stable per-item outcomes; workspace paths, event bodies, notes, attachment paths, and confirmation credentials stay out of logs and rendered results.
164
164
  - Import limits ZIP size, entries, paths, versions, and JSON structure, rejecting traversal, duplicates, and prototype-pollution keys.
165
165
  - Ordinary delete never invokes physical purge; only a committed recycle record can enter purge.
@@ -171,9 +171,9 @@ The browser never mutates files directly. After an operation, the Host response
171
171
 
172
172
  The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, physical location, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. A legacy persistence object with native `inspect` is retained unchanged. The current Host's `list()` snapshots and `open(id, 'read')` handles are adapted into a private `list` / `listSnapshots` / `inspect` read view; every inspection reads from offset 0 and closes its handle after success or failure. The view does not invent `create`, `append`, or `locate`. Ordinary sessions remain browsable, exportable, and snapshot-capable, while session-directory accounting, restore writes, and permanent purge are unavailable through this read-only view. Without physical location, purge returns `purge-unsupported` before changing recycle state, snapshots, pending markers, or live sessions.
173
173
 
174
- Snapshot and ZIP schema v1 cannot preserve `inheritedEventCount`. When a current read handle reports an inherited prefix greater than zero, the adapter returns `session-inspection-unsupported` before reading events and still closes the handle, avoiding a silently flattened branch history. Workspace archive itself retains the Host's successful result, while the associated History capture reports its existing per-item snapshot failure. This UI change does not widen the snapshot or restore protocol.
174
+ Snapshot and ZIP schema v1 cannot preserve `inheritedEventCount`. When a current read handle reports an inherited prefix greater than zero, the adapter returns `session-inspection-unsupported` before reading events and still closes the handle, avoiding a silently flattened branch history. Workspace archive no longer depends on snapshot capture. Recycle protection and ZIP export continue to reject unsupported inherited event data. This UI change does not widen the snapshot or restore protocol.
175
175
 
176
- On Hosts with the compatible legacy writer surface, Import, History restore-as-copy, and snapshot fallback when the original is missing still write through the public `create` / `append` / `locate` capability, or a dedicated restore entry point where the Host offers one; only a Host exposing neither returns `restore-unsupported` without mutation. A capability set that no shipped Host satisfies is not an acceptable guard — it makes the feature permanently dead rather than gracefully degraded. Back up the complete plugin-data directory before downgrading to a release that does not display History or understand recycle snapshots.
176
+ On Hosts with the compatible legacy writer surface, Import, legacy-data recovery, and snapshot fallback when the original is missing still write through the public `create` / `append` / `locate` capability, or a dedicated restore entry point where the Host offers one; only a Host exposing neither returns `restore-unsupported` without mutation. A capability set that no shipped Host satisfies is not an acceptable guard — it makes the feature permanently dead rather than gracefully degraded. Back up the complete plugin-data directory before downgrading to a release that does not display legacy data or understand recycle snapshots.
177
177
 
178
178
  Coverage includes:
179
179
 
@@ -185,7 +185,7 @@ Coverage includes:
185
185
  - search.js message projection, Unicode search, pagination, partial failures, and TTL/LRU caching.
186
186
  - trash.js, snapshot.js, and recycle.js format validation, concurrency, recovery, rollback, crash intent, and legacy migration.
187
187
  - insights.js, retention.js, retention-service.js, and lineage.js trusted accounting, policy bounds, short-lived authority, revalidation, and bounded graph projection.
188
- - history.js and history-restore.js revision deduplication, cache invalidation, snapshot authorization, single-use confirmation, transaction rollback, and source immutability.
188
+ - history.js and history-restore.js legacy-data preservation, cache invalidation, snapshot authorization, single-use confirmation, transaction rollback, and source immutability.
189
189
  - Host routes and browser settings smoke/responsive behavior.
190
190
 
191
191
  Run:
@@ -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/lineage.js 和 lib/workspace-bulk-archive.js 中。lib/persistence-compat.js 将新版 Host 的句柄读取面收敛为插件内部只读视图;lib/history.js 负责历史抓取、安全清单和预览授权,lib/history-restore.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/lineage.js 和 lib/workspace-bulk-archive.js 中。lib/persistence-compat.js 将新版 Host 的句柄读取面收敛为插件内部只读视图;lib/history.js 负责旧版数据安全清单和预览授权,lib/history-restore.js 负责单次确认的恢复为副本事务。lib/trash.js 负责版本化回收目录,lib/snapshot.js 负责可验证快照,lib/recycle.js 组合回收生命周期。
14
14
 
15
15
  浏览器不直接访问会话文件。所有读取和写入都经 Host 路由完成。
16
16
 
@@ -54,9 +54,9 @@ POST /plugins/dsh-archived-chats/delete
54
54
  POST /plugins/dsh-archived-chats/delete-all
55
55
  ~~~
56
56
 
57
- 所有修改路由以及会返回对话内容的 preview、preview/image、search、history/preview 和 history/preview/image 路由都要求 `x-dsh-archived-chats: 1` 请求头。`GET /history` 只返回有界安全清单;历史图片只在快照身份与完整描述符同时匹配时返回。
57
+ 所有修改路由以及会返回对话内容的 preview、preview/image、search、history/preview 和 history/preview/image 路由都要求 `x-dsh-archived-chats: 1` 请求头。`GET /history` 只返回有界安全清单;旧版数据图片只在快照身份与完整描述符同时匹配时返回。
58
58
 
59
- 工作区归档只列出安全的工作区摘要。preview 只接受一个工作区 ID,并为最多 2,000 条符合条件且有序的会话 ID 签发 5 分钟有效、只能使用一次的 token/nonce;apply 只接受该 token 和 nonce,绝不接受调用方指定的会话 ID。符合条件要求会话仍属于现有工作区、尚未归档、没有非空闲 Host agent,并且检查到活动或持久化事件日志包含 `turn/start`;空白的新会话窗口标记为 `session-empty`,检查失败标记为 `session-unavailable`,两者都会保守跳过。Host 不提供 agent 状态时,已加载会话会被保守视为活动;候选内容检查最多并发读取 8 条。每一项都会在共享生命周期队列内重新检查归属、归档状态、agent 状态和对话内容,然后以 registry 为 receiver 调用公开的 `workspaceRegistry.archiveSession()`。预览后新增的聊天不会纳入;已变为运行中、空白、不可确认、已归档或脱离工作区的项目会明确跳过。成功归档后会在持有生命周期锁时尝试保存历史版本;抓取失败会报告但不撤销归档,后续项目仍继续。此功能不改变工作区成员关系、路径或目录,也不会在工作区之间移动聊天。Host 没有公开 `archiveSession` 时返回 `workspace-archive-unsupported`,且不作修改。
59
+ 工作区归档只列出安全的工作区摘要。preview 只接受一个工作区 ID,并为最多 2,000 条符合条件且有序的会话 ID 签发 5 分钟有效、只能使用一次的 token/nonce;apply 只接受该 token 和 nonce,绝不接受调用方指定的会话 ID。符合条件要求会话仍属于现有工作区、尚未归档、没有非空闲 Host agent,并且检查到活动或持久化事件日志包含 `turn/start`;空白的新会话窗口标记为 `session-empty`,检查失败标记为 `session-unavailable`,两者都会保守跳过。Host 不提供 agent 状态时,已加载会话会被保守视为活动;候选内容检查最多并发读取 8 条。每一项都会在共享生命周期队列内重新检查归属、归档状态、agent 状态和对话内容,然后以 registry 为 receiver 调用公开的 `workspaceRegistry.archiveSession()`。预览后新增的聊天不会纳入;已变为运行中、空白、不可确认、已归档或脱离工作区的项目会明确跳过。成功归档不再抓取快照。此功能不改变工作区成员关系、路径或目录,也不会在工作区之间移动聊天。Host 没有公开 `archiveSession` 时返回 `workspace-archive-unsupported`,且不作修改。
60
60
 
61
61
  ## 状态和本地数据
62
62
 
@@ -73,7 +73,7 @@ $DSH_HOME/plugin-data/archived-chats/snapshots/
73
73
 
74
74
  stats 路由以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒。测量失败只标记当前行不可用,不阻塞列表和其他操作;删除会使对应缓存失效。
75
75
 
76
- insights 将会话目录测量与流式校验的快照清单分账,重复附件只按快照内已验证 SHA-256 统计;浏览器只在摘要卡片中显示总量,会话目录和快照明细通过有界、可搜索弹窗按需呈现。retention.json 使用精确 version 1 schema;保存策略不执行清理。preview 生成五分钟、单次使用的 token/nonce,apply 在生命周期队列内重检候选,回收站候选仍委托 recycle purge。lineage 只用持久化 parentSession 建树,不修改会话头;对已经进入聚焦关系树但没有安全标题的活动来源节点,最多按需读取 100 个标题事件。其 5,000 节点上限约束的是**实际投影出的**关系图,而不是 Host 的会话库:`focusIds` 已把输出收窄为归档与回收聊天加上解释它们所需的上下文,因此 100,000 个会话、25 条归档只投影 25 个节点。会话头字段按节点逐项收敛而不是整图校验——未预期的 `origin` 取值、缺失的 `createdAt`、变化的数值类型,各自只把该节点降级为公开 LineageNode 已允许的取值,只有身份不可用才丢弃该行。为一个不认识的会话头拒绝整张图,会让 Host 侧的变化在插件毫无改动的情况下直接停用该面板;畸形的工作区与回收记录出于同样理由被跳过。
76
+ insights 将会话目录测量与流式校验的快照清单分账,重复附件只按快照内已验证 SHA-256 统计;浏览器只在摘要卡片中显示总量,会话目录和快照明细通过有界、可搜索弹窗按需呈现。retention.json 保持兼容的 version 1 schema;只有回收站保留天数参与清理规划,旧版快照数量、年龄与容量字段不再规划快照删除;保存策略不执行清理。preview 生成五分钟、单次使用的 token/nonce,apply 在生命周期队列内重检候选,回收站候选仍委托 recycle purge。lineage 只用持久化 parentSession 建树,不修改会话头;对已经进入聚焦关系树但没有安全标题的活动来源节点,最多按需读取 100 个标题事件。其 5,000 节点上限约束的是**实际投影出的**关系图,而不是 Host 的会话库:`focusIds` 已把输出收窄为归档与回收聊天加上解释它们所需的上下文,因此 100,000 个会话、25 条归档只投影 25 个节点。会话头字段按节点逐项收敛而不是整图校验——未预期的 `origin` 取值、缺失的 `createdAt`、变化的数值类型,各自只把该节点降级为公开 LineageNode 已允许的取值,只有身份不可用才丢弃该行。为一个不认识的会话头拒绝整张图,会让 Host 侧的变化在插件毫无改动的情况下直接停用该面板;畸形的工作区与回收记录出于同样理由被跳过。
77
77
 
78
78
  ## 预览和全文搜索
79
79
 
@@ -83,15 +83,15 @@ preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chat
83
83
 
84
84
  跨会话搜索的持久层读取并发上限为 4,达到命中上限后不再调度后续批次,浏览器的新搜索会中止旧请求;单个会话失败会记入 skipped,其他命中仍正常返回。规范投影使用 30 秒 TTL、64 会话 LRU,并限制单段 256 Ki Unicode 码点、单消息 1 Mi 码点/1,000 段、单会话 10,000 条投影消息;结构化未知值在 stringify 前即受深度、节点和字符预算约束。超出投影边界的内容会安全截断且不会常驻缓存。取消归档、删除和恢复会使相关缓存失效。
85
85
 
86
- ## 历史版本与恢复为副本
86
+ ## 旧版数据兼容与恢复
87
87
 
88
- `history/capture` 只在浏览器包装的公开 `workspaces.archiveSession` 成功后调用。Host 进入与回收/保留共用的生命周期队列,重新检查归档所有权与回收状态,为仍存活的会话要求稳定 Host 修订,并复用同一非空修订的健康快照。插件不扫描无关活动会话,不在启动、定时器或后台自动抓取。
88
+ 归档不再调用 `history/capture`,该兼容路由拒绝新建历史版本。旧版快照在升级时保留,现有读取、预览、恢复和删除路由供「空间与策略 旧版数据」使用。回收站仍可创建恢复所需的保护快照。
89
89
 
90
90
  `history.js` 将已发布快照分组为 `archived` / `recycled` / `history-only`,单次最多检查 5,000 个快照目录,共用进行中请求并缓存已完成结果 30 秒。清单只含安全标题/工作区标题、时间、大小、附件数和保护状态;降级项只显示快照 ID 与稳定代码。分页预览与图片读取每次都重新验证快照、摘要和完整描述符,不返回路径或原始记录。
91
91
 
92
92
  `history-restore.js` 先完整验证快照,用 Host 生成新会话 ID,再签发五分钟、单次使用的 token/nonce。确认时先消费凭据并重验 manifest;然后依次创建持久会话、重写会话/附件身份、附加事件、恢复工作区和元数据,最后才写入归档注册表。任一插件控制的边界失败都按逆序回滚;来源会话与快照始终不变,也不声称删除了 Host 全局附件对象。
93
93
 
94
- 历史页的单条删除与「清空历史版本」都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与回收保护关系。两者都拒绝仍被回收记录引用的快照(无论是否降级)——那条记录是它最后的归属。除此之外两者都接受降级快照:它已无法预览或恢复,但字节仍在磁盘上,而其他任何入口都无法回收它们(保留策略只规划健康快照),在这里拒绝就意味着永久泄漏。删除会物理移除插件快照及其附件副本,但不修改原聊天或其他版本。
94
+ 旧版数据入口的单条删除与清空操作都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与回收保护关系。两者都拒绝仍被回收记录引用的快照(无论是否降级)——那条记录是它最后的归属。除此之外两者都接受降级快照:它已无法预览或恢复,但字节仍在磁盘上,而其他任何入口都无法回收它们(保留策略现只规划回收站永久删除),在这里拒绝就意味着永久泄漏。删除会物理移除插件快照及其附件副本,但不修改原聊天或其他版本。
95
95
 
96
96
  ## 导出流程
97
97
 
@@ -118,17 +118,17 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
118
118
  5. restore.js 通过能力探测的适配器写入会话、元数据和归档状态。
119
119
  6. 任一步骤失败都回滚暂存数据,不覆盖已有会话。
120
120
 
121
- 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是历史版本恢复为副本写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
121
+ 确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是旧版数据恢复写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
122
122
 
123
123
  ## 回收与保护快照生命周期
124
124
 
125
125
  `trash.json` 的合法状态只有 `trashed`、`purge-pending`、`degraded`。合法转换为 `missing -> trashed`、`trashed/degraded -> purge-pending`,以及任一现有状态在事务成功后移除。`purge-pending` 不得恢复。
126
126
 
127
- 保护快照格式是 `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 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
127
+ 保护快照格式是 `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 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
128
128
 
129
129
  移入顺序为:校验归档所有权 → 处置/停放运行中会话 → 捕获并验证快照 → 再次校验所有权 → 原子写入 `trashed` 记录 → 使缓存失效。普通移入不删除持久层文件。
130
130
 
131
- 恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除回收记录,不重写持久层,保护快照保留为历史;原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
131
+ 恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除回收记录,不重写持久层,保护快照保留在旧版数据入口;原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
132
132
 
133
133
  永久删除在任何物理写入前持久化 `purge-pending`,接着删除该来源的全部快照,然后删除原会话,最后移除回收记录。会话删除刻意放在最后:在它之前失败会保留完好的原件和可继续完成的记录,而不是留下一条会话已消失、既不能恢复也不能完成的 `purge-pending`。快照清扫按 manifest 身份归属每个已发布快照,因此校验失败但确属该会话的快照仍会被删除,而无关的无法校验快照会被跳过而不是中断整个清扫——快照库别处的损坏绝不能让永久删除变成不可能。回收记录还会点名自己的快照 id,覆盖损坏到无法归属的那一个。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。物理删除另外要求定位到的文件位于以该会话自身命名的目录中,因此当后端布局让多个会话共享同一父目录时,那个父目录永远不会被删除。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
134
134
 
@@ -136,13 +136,13 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
136
136
 
137
137
  client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用 Host 公开的归档服务和设计令牌。工作区归档 UI 状态保存在插件自己的设置区内,不依赖工作区操作 slot 或共享客户端 store。页面状态包括:
138
138
 
139
- - `shell.overlay` 中的归档成功提示:插件在 effect 生命周期内包装公开的 `workspaces.archiveSession`,只在原调用成功后发起历史抓取。抓取进行时暂停 3 秒关闭计时,成功后恢复,失败时显示不回滚归档的重试保存;查看与撤销继续可用。
140
- - **设置 → 会话档案** 中的 **批量归档工作区** 操作:先打开工作区选择器,再准备所选工作区并显示一次包含精确符合条件数量、已归档去向和仅在非零时出现的活动会话跳过数量的确认,不显示会话预览。全部成功后刷新消费者并关闭;存在跳过、失败或快照失败时保留逐项结果直至关闭。
139
+ - `shell.overlay` 中的归档成功提示:插件在 effect 生命周期内包装公开的 `workspaces.archiveSession`,只在原调用成功后显示提示,提供查看与撤销,3 秒后关闭,不请求历史抓取。
140
+ - **设置 → 会话档案** 中的 **批量归档工作区** 操作:先打开工作区选择器,再准备所选工作区并显示一次包含精确符合条件数量、已归档去向和仅在非零时出现的活动会话跳过数量的确认,不显示会话预览。全部成功后刷新消费者并关闭;存在跳过或失败时保留逐项结果直至关闭。
141
141
  - 归档列表和工作区分组。
142
142
  - 搜索、类型/项目/标签筛选和排序。
143
143
  - 标签备注编辑器。
144
144
  - 选中项批量导出、取消归档和移入回收站。
145
- - 归档、历史版本、回收站、空间与策略、来源与分支五标签。历史首次激活才请求安全清单,会话组默认折叠;预览复用对话弹窗并显示快照时间,恢复确认的初始焦点位于取消,token/nonce 不进入渲染树。其他空间与关系视图保留按需加载、有界弹窗和只读关系投影。
145
+ - 已归档、回收站、空间与策略、来源与分支四标签。空间与策略按需提供旧版数据预览、恢复为新的归档副本和确认删除;恢复确认的初始焦点位于取消,token/nonce 不进入渲染树。空间与关系视图保留有界弹窗和只读关系投影。
146
146
  - 导入预览、冲突禁用和恢复结果。
147
147
  - 响应式设置页标记和侧边栏刷新注入面。
148
148
 
@@ -157,7 +157,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
157
157
  ## 安全和失败策略
158
158
 
159
159
  - 所有状态变更路由都要求 POST 和 guard header。
160
- - 历史响应不包含工作区/快照/附件路径、原始事件、备注或确认 token;日志只记 ID 和稳定代码。
160
+ - 旧版数据响应不包含工作区/快照/附件路径、原始事件、备注或确认 token;日志只记 ID 和稳定代码。
161
161
  - 工作区归档响应只公开安全的工作区 ID/标题、符合条件数量、已确认预览中的会话 ID/标题/时间和稳定的逐项结果;工作区路径、事件正文、备注、附件路径与确认凭据不会进入日志或渲染结果。
162
162
  - 导入限制 ZIP 大小、条目数量、路径格式、版本和 JSON 结构,拒绝遍历、重复和原型污染字段。
163
163
  - 普通删除从不调用物理清除;仅已提交回收记录可进入 purge。归档列表还提供单独确认的直接永久删除操作,跳过快照和回收站,但复用同一套会话目录边界、崩溃恢复和失败保护;普通删除仍默认可恢复。
@@ -169,9 +169,9 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
169
169
 
170
170
  插件通过能力检测适配 Host:归档读取、附件读取、持久层写入、物理定位和运行中会话生命周期能力分别判断,缺失能力必须安全降级或返回明确错误。带原生 `inspect` 的旧版持久层对象保持原样;新版 Host 的 `list()` 快照和 `open(id, 'read')` 句柄被适配为内部 `list` / `listSnapshots` / `inspect` 只读视图,读取始终从偏移 0 开始并在成功或失败后关闭句柄。这个视图不虚构 `create`、`append` 或 `locate`。因此普通会话可以继续浏览、导出和抓取快照,但会话目录空间显示不可用,这个只读视图上的恢复写入与永久清除也不可用;缺少物理定位时,永久清除会在修改回收状态、快照、待处理标记或运行中会话之前以 `purge-unsupported` 拒绝。
171
171
 
172
- 当前 v1 快照和 ZIP schema 不能保存 `inheritedEventCount`。新版读取句柄报告大于 0 的继承前缀时,适配器会在读取事件前以 `session-inspection-unsupported` 拒绝并关闭句柄,避免把分支历史静默展平。工作区归档本身仍按 Host 结果成功,关联的历史抓取则通过现有逐项结果显示失败。这个 UI 变更不扩展快照/恢复协议。
172
+ 当前 v1 快照和 ZIP schema 不能保存 `inheritedEventCount`。新版读取句柄报告大于 0 的继承前缀时,适配器会在读取事件前以 `session-inspection-unsupported` 拒绝并关闭句柄,避免把分支历史静默展平。工作区归档不再依赖快照抓取;回收保护与 ZIP 导出仍会拒绝不支持的继承事件数据。这个 UI 变更不扩展快照/恢复协议。
173
173
 
174
- 兼容旧写入面的 Host 仍可让导入、历史版本恢复为副本和原件丢失时的快照回退通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。旧版若不显示历史页或不识别回收快照,降级前应备份整个插件数据目录。
174
+ 兼容旧写入面的 Host 仍可让导入、旧版数据恢复和原件丢失时的快照回退通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。旧版若不显示旧版数据或不识别回收快照,降级前应备份整个插件数据目录。
175
175
 
176
176
  测试覆盖:
177
177
 
@@ -183,7 +183,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
183
183
  - search.js 的消息投影、Unicode 搜索、分页、部分失败与 TTL/LRU 缓存。
184
184
  - trash.js、snapshot.js 和 recycle.js 的格式验证、并发、恢复、回滚、崩溃意图和旧标记迁移。
185
185
  - insights.js、retention.js、retention-service.js 和 lineage.js 的可信分账、策略边界、短效授权、重检和有界图投影。
186
- - history.js 和 history-restore.js 的修订去重、缓存失效、快照授权、单次确认、事务回滚与来源不变式。
186
+ - history.js 和 history-restore.js 的旧版数据保留、缓存失效、快照授权、单次确认、事务回滚与来源不变式。
187
187
  - Host 路由和浏览器设置页的冒烟及响应式行为。
188
188
 
189
189
  运行:
package/lib/client.js CHANGED
@@ -61,7 +61,6 @@ window.__ModuleLoader__.load({
61
61
  cancel = (timer) => clearTimeout(timer),
62
62
  undo: performUndo = async () => {},
63
63
  view: performView = async () => true,
64
- capture: performCapture = null,
65
64
  } = {}) {
66
65
  const listeners = new Set();
67
66
  const timeout = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 3000;
@@ -82,8 +81,7 @@ window.__ModuleLoader__.load({
82
81
  publish();
83
82
  };
84
83
  const arm = () => {
85
- if (snapshot === null || timer !== null || pauseReasons.size > 0 || snapshot.status !== "ready"
86
- || snapshot.captureStatus === "saving" || snapshot.captureStatus === "error") return;
84
+ if (snapshot === null || timer !== null || pauseReasons.size > 0 || snapshot.status !== "ready") return;
87
85
  const expected = snapshot;
88
86
  timer = schedule(() => {
89
87
  timer = null;
@@ -109,48 +107,19 @@ window.__ModuleLoader__.load({
109
107
  return false;
110
108
  }
111
109
  };
112
- const capture = async (expected) => {
113
- if (typeof performCapture !== "function" || snapshot !== expected) return true;
114
- try {
115
- const result = await performCapture(expected.sessionId);
116
- if (result === false) throw new Error("history capture failed");
117
- if (snapshot === expected) {
118
- snapshot = { ...expected, captureStatus: "saved" };
119
- publish();
120
- arm();
121
- }
122
- return true;
123
- } catch {
124
- if (snapshot === expected) {
125
- snapshot = { ...expected, captureStatus: "error" };
126
- publish();
127
- }
128
- return false;
129
- }
130
- };
131
110
  return {
132
111
  getSnapshot: () => snapshot,
133
112
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
134
113
  show(sessionId) {
135
114
  if (typeof sessionId !== "string" || sessionId === "") return;
136
115
  clearTimer();
137
- snapshot = { sessionId, status: "ready", captureStatus: typeof performCapture === "function" ? "saving" : null };
138
- const expected = snapshot;
116
+ snapshot = { sessionId, status: "ready" };
139
117
  publish();
140
- if (typeof performCapture === "function") void capture(expected);
141
- else arm();
118
+ arm();
142
119
  },
143
120
  pause(reason = "interaction") { pauseReasons.add(reason); clearTimer(); },
144
121
  resume(reason = "interaction") { pauseReasons.delete(reason); arm(); },
145
122
  dismiss,
146
- async retryCapture() {
147
- if (snapshot === null || snapshot.captureStatus !== "error" || typeof performCapture !== "function") return false;
148
- clearTimer();
149
- snapshot = { ...snapshot, status: "ready", captureStatus: "saving" };
150
- const expected = snapshot;
151
- publish();
152
- return capture(expected);
153
- },
154
123
  undo: () => run("undoing", "undo-error", performUndo),
155
124
  view: () => run("viewing", "view-error", performView),
156
125
  };
@@ -215,54 +184,51 @@ window.__ModuleLoader__.load({
215
184
  "archiveNotice.undo": "撤销",
216
185
  "archiveNotice.undoing": "正在撤销…",
217
186
  "archiveNotice.retry": "重试",
218
- "archiveNotice.captureSaving": "已归档,正在保存历史版本",
219
- "archiveNotice.captureSaved": "已归档,历史版本已保存",
220
- "archiveNotice.captureError": "已归档,但历史版本未保存",
221
- "archiveNotice.captureRetry": "重试保存",
222
187
  "archiveNotice.close": "关闭归档提示",
223
188
  "archiveNotice.settings": "设置",
224
189
  "archiveNotice.undoError": "撤销失败,请重试",
225
190
  "archiveNotice.viewError": "无法打开会话档案,请重试",
226
191
  "page.title": "会话档案",
227
192
  "tab.archived": "归档",
228
- "tab.history": "历史版本",
193
+ "tab.history": "旧版数据",
194
+ "legacy.title": "旧版数据",
229
195
  "tab.trash": "回收站",
230
196
  "tab.insights": "空间与策略",
231
197
  "tab.lineage": "来源与分支",
232
- "history.note": "历史版本保存在本机,会在成功归档后或移入回收站前创建。",
198
+ "history.note": "这里只保留旧版快照及恢复后留下的保护数据。归档不再创建历史版本。可预览、恢复为归档副本后导出,或确认删除;回收站保护数据请从回收站恢复。",
233
199
  "history.search": "搜索聊天或项目",
234
- "history.loading": "正在加载历史版本…",
235
- "history.error": "历史版本暂不可用",
236
- "history.empty": "成功归档聊天后,第一个历史版本会显示在这里。",
237
- "history.emptyFiltered": "没有匹配的历史版本",
238
- "history.expand": "展开历史版本",
239
- "history.collapse": "折叠历史版本",
200
+ "history.loading": "正在加载旧版数据…",
201
+ "history.error": "旧版数据暂不可用",
202
+ "history.empty": "没有需要迁移的旧版数据。",
203
+ "history.emptyFiltered": "没有匹配的旧版数据",
204
+ "history.expand": "展开旧版数据",
205
+ "history.collapse": "折叠旧版数据",
240
206
  "history.preview": "预览",
241
207
  "history.snapshotAt": "快照时间",
242
- "history.restore": "恢复为副本",
243
- "history.restoreTitle": "恢复历史版本为副本?",
208
+ "history.restore": "恢复旧版数据",
209
+ "history.restoreTitle": "恢复旧版数据为归档副本?",
244
210
  "history.restoreConfirm": "创建副本",
245
211
  "history.restoreAgain": "重新准备",
246
212
  "history.restoreError": "恢复确认已失效,请重新准备。",
247
213
  "history.restoreWorkspaceWarning": "原项目不可用,副本将作为未分组归档恢复。",
248
214
  "history.delete": "删除",
249
- "history.deleteTitle": "删除这个历史版本?",
215
+ "history.deleteTitle": "删除这个旧版数据?",
250
216
  "history.deleteConfirm": "永久删除",
251
217
  "history.deleteProtected": "该版本用于回收站恢复,不能删除",
252
- "history.clear": "清空历史版本",
253
- "history.clearTitle": "清空所有历史版本?",
254
- "history.deleteError": "历史版本删除失败",
218
+ "history.clear": "清空旧版数据",
219
+ "history.clearTitle": "清空所有旧版数据?",
220
+ "history.deleteError": "旧版数据删除失败",
255
221
  "history.attachments": "个附件",
256
- "history.state.history": "历史版本",
222
+ "history.state.history": "旧版数据",
257
223
  "history.state.recycle-protection": "回收站保护",
258
224
  "history.health.ready": "可用",
259
225
  "history.scope.archived": "已归档",
260
226
  "history.scope.recycled": "回收站",
261
227
  "history.scope.history-only": "仅历史保留",
262
- "history.degraded": "无法读取的历史版本",
228
+ "history.degraded": "无法读取的旧版数据",
263
229
  "history.degradedItem": "无法验证",
264
230
  "history.degradedDelete": "清除",
265
- "history.degradedDeleteTitle": "清除这个无法读取的历史版本?",
231
+ "history.degradedDeleteTitle": "清除这个无法读取的旧版数据?",
266
232
  "trash.listUnverified": "回收站目录无法读取:下面的列表可能包含已删除的聊天,归档修改已暂停。",
267
233
  "insights.loading": "正在分析空间…",
268
234
  "insights.error": "空间分析暂不可用",
@@ -298,7 +264,7 @@ window.__ModuleLoader__.load({
298
264
  "retention.apply": "应用所选清理",
299
265
  "retention.empty": "当前策略没有清理候选",
300
266
  "retention.saved": "策略已保存",
301
- "retention.note": "保留数量按每个原会话分别计算;保存策略不会删除任何数据,必须再预览并应用清理。",
267
+ "retention.note": "仅对回收站生成清理建议。保存不会删除数据,清理前必须预览并确认。旧版快照不会按旧策略自动清理。",
302
268
  "retention.snapshotCandidate": "历史快照清理",
303
269
  "retention.trashCandidate": "永久删除回收站会话",
304
270
  "retention.projected": "清理后快照占用",
@@ -518,54 +484,51 @@ window.__ModuleLoader__.load({
518
484
  "archiveNotice.undo": "Undo",
519
485
  "archiveNotice.undoing": "Undoing…",
520
486
  "archiveNotice.retry": "Retry",
521
- "archiveNotice.captureSaving": "Archived, saving history version",
522
- "archiveNotice.captureSaved": "Archived, history version saved",
523
- "archiveNotice.captureError": "Archived, but history version was not saved",
524
- "archiveNotice.captureRetry": "Retry save",
525
487
  "archiveNotice.close": "Close archive notice",
526
488
  "archiveNotice.settings": "Settings",
527
489
  "archiveNotice.undoError": "Undo failed. Try again",
528
490
  "archiveNotice.viewError": "Could not open Session Archive. Try again",
529
491
  "page.title": "Session Archive",
530
492
  "tab.archived": "Archived",
531
- "tab.history": "History",
493
+ "tab.history": "Legacy data",
494
+ "legacy.title": "Legacy data",
532
495
  "tab.trash": "Recycle Bin",
533
496
  "tab.insights": "Storage & Retention",
534
497
  "tab.lineage": "Origins & Branches",
535
- "history.note": "History versions are stored locally and created after archive or before a chat moves to the Recycle Bin.",
498
+ "history.note": "Existing snapshots and recovery data are preserved here. Archiving no longer creates versions. Preview, recover an archived copy for export, or confirm deletion. Restore protected chats from the Recycle Bin.",
536
499
  "history.search": "Search chats or projects",
537
- "history.loading": "Loading history versions…",
538
- "history.error": "History versions are unavailable",
539
- "history.empty": "The first history version will appear here after a chat is archived successfully.",
540
- "history.emptyFiltered": "No history versions match",
541
- "history.expand": "Expand history versions",
542
- "history.collapse": "Collapse history versions",
500
+ "history.loading": "Loading legacy snapshots…",
501
+ "history.error": "Legacy snapshots are unavailable",
502
+ "history.empty": "No legacy data to migrate.",
503
+ "history.emptyFiltered": "No legacy snapshots match",
504
+ "history.expand": "Expand legacy snapshots",
505
+ "history.collapse": "Collapse legacy snapshots",
543
506
  "history.preview": "Preview",
544
507
  "history.snapshotAt": "Snapshot",
545
- "history.restore": "Restore as copy",
546
- "history.restoreTitle": "Restore history version as a copy?",
508
+ "history.restore": "Recover legacy data",
509
+ "history.restoreTitle": "Recover legacy data as an archived copy?",
547
510
  "history.restoreConfirm": "Create copy",
548
511
  "history.restoreAgain": "Prepare again",
549
512
  "history.restoreError": "The restore confirmation is no longer valid. Prepare it again.",
550
513
  "history.restoreWorkspaceWarning": "The original project is unavailable, so the copy will be restored as an ungrouped archive.",
551
514
  "history.delete": "Delete",
552
- "history.deleteTitle": "Delete this history version?",
515
+ "history.deleteTitle": "Delete this legacy snapshot?",
553
516
  "history.deleteConfirm": "Delete permanently",
554
517
  "history.deleteProtected": "This version protects Recycle Bin recovery and cannot be deleted",
555
- "history.clear": "Clear history versions",
556
- "history.clearTitle": "Clear all history versions?",
557
- "history.deleteError": "History version deletion failed",
518
+ "history.clear": "Clear legacy data",
519
+ "history.clearTitle": "Clear all legacy data?",
520
+ "history.deleteError": "Legacy snapshot deletion failed",
558
521
  "history.attachments": "attachments",
559
- "history.state.history": "History version",
522
+ "history.state.history": "Legacy snapshot",
560
523
  "history.state.recycle-protection": "Recycle protection",
561
524
  "history.health.ready": "Available",
562
525
  "history.scope.archived": "Archived",
563
526
  "history.scope.recycled": "Recycle Bin",
564
527
  "history.scope.history-only": "History only",
565
- "history.degraded": "Unreadable history versions",
528
+ "history.degraded": "Unreadable legacy snapshots",
566
529
  "history.degradedItem": "Could not verify",
567
530
  "history.degradedDelete": "Reclaim",
568
- "history.degradedDeleteTitle": "Reclaim this unreadable history version?",
531
+ "history.degradedDeleteTitle": "Reclaim this unreadable legacy snapshot?",
569
532
  "trash.listUnverified": "The Recycle Bin catalog is unreadable: this list may include deleted chats, and archive changes are paused.",
570
533
  "insights.loading": "Analyzing storage…",
571
534
  "insights.error": "Storage insights are unavailable",
@@ -601,7 +564,7 @@ window.__ModuleLoader__.load({
601
564
  "retention.apply": "Apply selected cleanup",
602
565
  "retention.empty": "The current policy has no cleanup candidates",
603
566
  "retention.saved": "Policy saved",
604
- "retention.note": "The retention count applies separately to each original chat. Saving never deletes data; preview and apply cleanup separately.",
567
+ "retention.note": "Cleanup suggestions apply only to the Recycle Bin. Saving never deletes data; preview and confirm cleanup. Old snapshot policies no longer apply.",
605
568
  "retention.snapshotCandidate": "Historical snapshot cleanup",
606
569
  "retention.trashCandidate": "Permanently delete recycled chat",
607
570
  "retention.projected": "Snapshot usage after cleanup",
@@ -1444,6 +1407,7 @@ window.__ModuleLoader__.load({
1444
1407
  .dac-workspace-body ul{margin:0;padding-left:20px;display:flex;flex-direction:column;gap:4px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}
1445
1408
  .dac-workspace-result-group{display:flex;flex-direction:column;gap:5px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:10px}
1446
1409
  @media (max-width:640px){[role="dialog"][data-dac-section-active="1"]>nav{display:none}[role="dialog"][data-dac-section-active="1"]>nav+div{width:100%;min-width:0}.dac-head{align-items:flex-start;flex-wrap:wrap}.dac-head-actions{width:100%;justify-content:flex-start}.dac-tabs{width:100%}.dac-action-trigger{padding:5px 9px}.dac-action-menu{left:0;right:auto;max-width:calc(100vw - 32px)}.dac-action-wrap:last-child .dac-action-menu{left:auto;right:0}.dac-selection-controls{width:100%;justify-content:space-between}.dac-bulkbar{align-items:flex-start;flex-direction:column}.dac-bulk-actions{width:100%;flex-wrap:wrap}.dac-trash-toolbar{align-items:flex-start;flex-direction:column}.dac-trash-toolbar-actions{width:100%}.dac-row{align-items:flex-start;flex-wrap:wrap}.dac-row-actions{gap:4px;flex-wrap:wrap}.dac-trash-actions{width:100%;justify-content:flex-end}.dac-unarchive{padding:5px 10px}.dac-summary{gap:6px}.dac-row-meta{gap:4px}.dac-search-state{display:none}.dac-history-version{align-items:flex-start;flex-direction:column}.dac-history-actions{width:100%}.dac-preview-dialog{width:calc(100vw - 20px);height:calc(100vh - 20px)}.dac-preview-layout{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr)}.dac-preview-feed{padding:10px}.dac-preview-rail{flex-direction:row;overflow-x:auto;border-right:none;border-bottom:1px solid var(--dsw-alias-border-l2);padding:8px 5px}.dac-preview-rail button{flex:none}}
1410
+ @media (max-width:640px){.dac-insights-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.dac-policy{grid-template-columns:minmax(0,1fr)}}
1447
1411
  @media (max-width:640px){.dac-lineage-filter-actions{width:100%;justify-content:flex-end}.dac-lineage-filter-actions .dac-select-wrap{flex:0 1 calc(50% - 4px);min-width:132px}.dac-lineage-list-head{align-items:flex-start;flex-direction:column}.dac-lineage-fold-actions{width:100%}.dac-lineage-fold{flex:1;justify-content:center}.dac-lineage-tree{max-height:min(560px,calc(100vh - 300px))}.dac-lineage-guide,.dac-lineage-junction,.dac-lineage-guide-overflow{width:14px}.dac-lineage-junction-child{width:28px}.dac-lineage-junction-child>.dac-lineage-toggle{left:9px}.dac-lineage-junction-child>.dac-lineage-dot{left:15px}.dac-lineage-context{align-items:flex-start;flex-direction:column;gap:2px}.dac-lineage-context small{margin-left:0;white-space:normal}.dac-lineage-detail{align-items:flex-start;flex-direction:column;gap:8px}.dac-lineage-id-actions{width:100%;justify-content:flex-end}}
1448
1412
  @media (max-width:640px){.dac-archive-notice{top:8px;left:50%;right:auto;width:max-content;max-width:calc(100% - 16px);min-height:42px;gap:6px;transform:translateX(-50%);padding:5px 7px 5px 10px;animation-name:dac-archive-notice-in}.dac-archive-notice-title{max-width:180px;font-size:13px;line-height:18px}.dac-archive-notice-actions{flex-wrap:wrap;justify-content:flex-end}.dac-archive-notice-action{height:28px;border-radius:9px;font-size:12px;padding:0 8px}.dac-archive-notice-close{width:24px;height:24px}}
1449
1413
  @media (max-width:480px){.dac-workspace-dialog{width:calc(100vw - 32px);padding:22px}.dac-workspace-dialog .dac-preview-head strong{font-size:20px;line-height:28px}.dac-workspace-copy{font-size:15px;line-height:24px}.dac-workspace-actions{flex-direction:row;justify-content:flex-end;flex-wrap:wrap}.dac-workspace-actions .dac-btn,.dac-workspace-actions .dac-btn-danger{width:auto;min-width:92px;padding:9px 14px}}
@@ -1817,23 +1781,19 @@ window.__ModuleLoader__.load({
1817
1781
  const busy = snapshot.status === "undoing" || snapshot.status === "viewing";
1818
1782
  const title = snapshot.status === "undo-error"
1819
1783
  ? t("archiveNotice.undoError")
1820
- : snapshot.status === "view-error" ? t("archiveNotice.viewError")
1821
- : snapshot.captureStatus === "saving" ? t("archiveNotice.captureSaving")
1822
- : snapshot.captureStatus === "saved" ? t("archiveNotice.captureSaved")
1823
- : snapshot.captureStatus === "error" ? t("archiveNotice.captureError") : t("archiveNotice.title");
1784
+ : snapshot.status === "view-error" ? t("archiveNotice.viewError") : t("archiveNotice.title");
1824
1785
  const viewLabel = snapshot.status === "viewing"
1825
1786
  ? t("archiveNotice.opening")
1826
1787
  : snapshot.status === "view-error" ? t("archiveNotice.retry") : t("archiveNotice.view");
1827
1788
  const undoLabel = snapshot.status === "undoing"
1828
1789
  ? t("archiveNotice.undoing")
1829
1790
  : snapshot.status === "undo-error" ? t("archiveNotice.retry") : t("archiveNotice.undo");
1830
- const captureError = snapshot.captureStatus === "error";
1831
1791
  return (0, jsx.jsxs)("div", {
1832
- className: `dac-archive-notice${snapshot.status.endsWith("-error") || captureError ? " dac-archive-notice-error" : ""}`,
1792
+ className: `dac-archive-notice${snapshot.status.endsWith("-error") ? " dac-archive-notice-error" : ""}`,
1833
1793
  role: "region",
1834
1794
  "aria-label": title,
1835
1795
  "aria-live": "polite",
1836
- "aria-busy": busy || snapshot.captureStatus === "saving",
1796
+ "aria-busy": busy,
1837
1797
  onMouseEnter: () => controller.pause("pointer"),
1838
1798
  onMouseLeave: () => controller.resume("pointer"),
1839
1799
  onFocusCapture: () => controller.pause("focus"),
@@ -1842,7 +1802,6 @@ window.__ModuleLoader__.load({
1842
1802
  (0, jsx.jsx)("span", { className: "dac-archive-notice-icon", "aria-hidden": "true", children: (0, jsx.jsx)(IconArchive, { size: 14 }) }),
1843
1803
  (0, jsx.jsx)("strong", { className: "dac-archive-notice-title", role: "status", children: title }),
1844
1804
  (0, jsx.jsxs)("span", { className: "dac-archive-notice-actions", children: [
1845
- captureError && (0, jsx.jsx)("button", { type: "button", className: "dac-archive-notice-action dac-archive-notice-view", autoFocus: true, onClick: () => { void controller.retryCapture(); }, children: t("archiveNotice.captureRetry") }),
1846
1805
  (0, jsx.jsx)("button", { type: "button", className: "dac-archive-notice-action dac-archive-notice-view", disabled: busy, onClick: () => { void controller.view(); }, children: viewLabel }),
1847
1806
  (0, jsx.jsx)("button", { type: "button", className: "dac-archive-notice-action dac-archive-notice-undo", disabled: busy, onClick: () => { void controller.undo(); }, children: undoLabel }),
1848
1807
  (0, jsx.jsx)("button", { type: "button", className: "dac-archive-notice-close", "aria-label": t("archiveNotice.close"), onClick: () => controller.dismiss(), children: (0, jsx.jsx)(IconClose, { size: 14 }) })
@@ -2037,8 +1996,7 @@ window.__ModuleLoader__.load({
2037
1996
  const archived = Array.isArray(applied?.archived) ? applied.archived : [];
2038
1997
  const skipped = Array.isArray(applied?.skipped) ? applied.skipped : [];
2039
1998
  const failed = Array.isArray(applied?.failed) ? applied.failed : [];
2040
- const snapshotFailed = Array.isArray(applied?.snapshots) && applied.snapshots.some((item) => item?.status === "snapshot-failed");
2041
- const fullSuccess = archived.length > 0 && skipped.length === 0 && failed.length === 0 && !snapshotFailed;
1999
+ const fullSuccess = archived.length > 0 && skipped.length === 0 && failed.length === 0;
2042
2000
  if (fullSuccess) {
2043
2001
  try { await onApplied?.(applied); } catch { /* refresh failure must not hold a completed dialog open */ }
2044
2002
  if (mountedRef.current && applyRequestRef.current === request) onClose?.();
@@ -2090,7 +2048,6 @@ window.__ModuleLoader__.load({
2090
2048
  resultRows(t("workspaceArchive.archived"), result?.archived),
2091
2049
  resultRows(t("workspaceArchive.skipped"), result?.skipped),
2092
2050
  resultRows(t("workspaceArchive.failed"), result?.failed),
2093
- resultRows(t("workspaceArchive.snapshotFailed"), result?.snapshots?.filter((item) => item?.status === "snapshot-failed")),
2094
2051
  (0, jsx.jsx)("button", { type: "button", className: "dac-btn-primary", onClick: close, children: t("workspaceArchive.done") })
2095
2052
  ] }),
2096
2053
  stage === "error" && (0, jsx.jsxs)("div", { className: "dac-workspace-body dac-error", role: "status", children: [
@@ -3152,7 +3109,8 @@ window.__ModuleLoader__.load({
3152
3109
  }) });
3153
3110
  }
3154
3111
 
3155
- function StorageRetentionPanel({ t }) {
3112
+ function StorageRetentionPanel({ t, onPreview, onRestored, fallbackFocusRef }) {
3113
+ const [legacyOpen, setLegacyOpen] = _react.useState(false);
3156
3114
  const [state, setState] = _react.useState({ status: "loading", value: null, error: null });
3157
3115
  const [draft, setDraft] = _react.useState(null);
3158
3116
  const [preview, setPreview] = _react.useState(null);
@@ -3202,9 +3160,11 @@ window.__ModuleLoader__.load({
3202
3160
  catch (error) { const body = error.body ?? {}; const applied = body.applied ?? []; const failed = body.failed ?? []; setMessage(failed.length > 0 ? `${t("retention.result")}: ${applied.length} / ${failed.length} · ${failed.map((item) => `${item.key}: ${item.reason}`).join(" · ")}` : String(error.message ?? error)); setPreview(null); setSelected(new Set()); }
3203
3161
  finally { setBusy(false); }
3204
3162
  };
3205
- if (state.status === "loading") return (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), t("insights.loading")] });
3206
- if (state.status === "error") return (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { children: t("insights.error") }), (0, jsx.jsx)("button", { className: "dac-retry", onClick: () => load(), children: t("state.retry") })] });
3207
- const value = state.value;
3163
+ const refreshLegacyStorage = async () => {
3164
+ try { const value = await fetchInsights(); setState({ status: "ready", value, error: null }); setDraft((current) => current ?? { ...value.policy }); }
3165
+ catch (error) { setState({ status: "error", value: null, error }); }
3166
+ };
3167
+ const value = state.value ?? { summary: {}, sessions: [], snapshots: [] };
3208
3168
  const cards = [
3209
3169
  ["insights.sessions", formatBytes(value.summary.sessionBytes), "sessions", (value.sessions ?? []).length, "insights.openSessions"],
3210
3170
  ["insights.snapshots", formatBytes(value.summary.snapshotBytes), "snapshots", (value.snapshots ?? []).length, "insights.openSnapshots"],
@@ -3213,6 +3173,9 @@ window.__ModuleLoader__.load({
3213
3173
  ["insights.unavailable", `${value.summary.sessionUnavailableCount} / ${value.summary.degradedSnapshotCount}`, null, 0, null]
3214
3174
  ];
3215
3175
  return (0, jsx.jsxs)("div", { className: "dac-mode-panel", children: [
3176
+ state.status === "loading" && (0, jsx.jsx)("div", { className: "dac-center", children: t("insights.loading") }),
3177
+ state.status === "error" && (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { children: t("insights.error") }), (0, jsx.jsx)("button", { className: "dac-retry", onClick: () => load(), children: t("state.retry") })] }),
3178
+ state.status === "ready" && (0, jsx.jsxs)("div", { className: "dac-mode-panel", children: [
3216
3179
  (0, jsx.jsx)("div", { className: "dac-context-note", role: "note", children: t("insights.scopeNote") }),
3217
3180
  (0, jsx.jsx)("div", { className: "dac-insights-grid", children: cards.map(([label, display, mode, count, actionLabel]) => (0, jsx.jsxs)("div", { className: "dac-insights-card", style: { textAlign: "center", alignItems: "center" }, children: [
3218
3181
  (0, jsx.jsx)("span", { children: t(label) }),
@@ -3221,13 +3184,13 @@ window.__ModuleLoader__.load({
3221
3184
  ] }, label)) }),
3222
3185
  message && (0, jsx.jsx)("div", { className: messageKind === "ok" ? "dac-toast" : "dac-notice", role: "status", children: message }),
3223
3186
  draft && (0, jsx.jsxs)("div", { className: "dac-policy", children: [
3224
- (0, jsx.jsxs)("label", { children: [(0, jsx.jsx)("span", { children: t("retention.history") }), (0, jsx.jsx)("input", { type: "number", min: 0, max: 20, value: draft.historicalSnapshotsPerSession, onChange: (event) => setNumber("historicalSnapshotsPerSession", event.target.value, false) })] }),
3225
- (0, jsx.jsxs)("label", { children: [(0, jsx.jsx)("span", { children: t("retention.snapshotAge") }), (0, jsx.jsx)("input", { type: "number", min: 1, max: 3650, placeholder: t("retention.disabled"), value: draft.historicalSnapshotMaxAgeDays ?? "", onChange: (event) => setNumber("historicalSnapshotMaxAgeDays", event.target.value, true) })] }),
3226
- (0, jsx.jsxs)("label", { children: [(0, jsx.jsx)("span", { children: t("retention.quota") }), (0, jsx.jsx)("input", { type: "number", min: 1048576, value: draft.snapshotQuotaBytes ?? "", placeholder: t("retention.disabled"), onChange: (event) => setNumber("snapshotQuotaBytes", event.target.value, true) })] }),
3227
3187
  (0, jsx.jsxs)("label", { children: [(0, jsx.jsx)("span", { children: t("retention.recycleAge") }), (0, jsx.jsx)("input", { type: "number", min: 1, max: 3650, value: draft.recycleMaxAgeDays ?? "", placeholder: t("retention.disabled"), onChange: (event) => setNumber("recycleMaxAgeDays", event.target.value, true) })] }),
3228
3188
  (0, jsx.jsx)("div", { className: "dac-policy-note", children: t("retention.note") }),
3229
3189
  (0, jsx.jsxs)("div", { className: "dac-policy-actions", children: [(0, jsx.jsx)("button", { className: "dac-btn", disabled: busy, onClick: save, children: t("retention.save") }), (0, jsx.jsx)("button", { className: "dac-btn", disabled: busy, onClick: openPreview, children: t("retention.preview") })] })
3230
3190
  ] }),
3191
+ ] }),
3192
+ (0, jsx.jsx)("button", { type: "button", className: "dac-btn", "aria-expanded": legacyOpen, onClick: () => setLegacyOpen((current) => !current), children: t("legacy.title") }),
3193
+ legacyOpen && (0, jsx.jsx)(HistoryPanel, { active: true, t, onPreview, onRestored, onChanged: refreshLegacyStorage, fallbackFocusRef }),
3231
3194
  preview && (0, jsx.jsx)(RetentionPreviewDialog, { preview, selected, t, busy, returnFocus: previewReturnFocusRef.current, onToggle: (key, checked) => setSelected((current) => setVisibleSelection(current, [key], checked)), onApply: applySelected, onCancel: closeRetentionPreview }),
3232
3195
  detailsMode && (0, jsx.jsx)(StorageDetailsDialog, { mode: detailsMode, sessions: value.sessions, snapshots: value.snapshots, t, returnFocus: detailsReturnFocusRef.current, onClose: () => setDetailsMode(null) })
3233
3196
  ] });
@@ -3411,7 +3374,7 @@ window.__ModuleLoader__.load({
3411
3374
  ] });
3412
3375
  }
3413
3376
 
3414
- function HistoryPanel({ active, t, onPreview, onRestored, fallbackFocusRef }) {
3377
+ function HistoryPanel({ active, t, onPreview, onRestored, onChanged, fallbackFocusRef }) {
3415
3378
  const [state, setState] = _react.useState({ status: "idle", sessions: [], degraded: [], error: null });
3416
3379
  const [query, setQuery] = _react.useState("");
3417
3380
  const [expanded, setExpanded] = _react.useState(() => new Set());
@@ -3428,7 +3391,7 @@ window.__ModuleLoader__.load({
3428
3391
  const body = await fetchHistory();
3429
3392
  setState({
3430
3393
  status: "ready",
3431
- sessions: Array.isArray(body?.sessions) ? body.sessions : [],
3394
+ sessions: Array.isArray(body?.sessions) ? body.sessions.map((session) => ({ ...session, versions: session.versions.filter((version) => version.state !== "recycle-protection") })).filter((session) => session.versions.length > 0) : [],
3432
3395
  degraded: Array.isArray(body?.degraded) ? body.degraded : [],
3433
3396
  error: null,
3434
3397
  });
@@ -3454,8 +3417,8 @@ window.__ModuleLoader__.load({
3454
3417
  && value.toLocaleLowerCase(locale).includes(needle));
3455
3418
  });
3456
3419
  const versionCount = (count) => isZh(t)
3457
- ? `${count} 个历史版本`
3458
- : `${count} history ${count === 1 ? "version" : "versions"}`;
3420
+ ? `${count} 份旧版数据`
3421
+ : `${count} legacy ${count === 1 ? "snapshot" : "snapshots"}`;
3459
3422
  const deletable = state.sessions.flatMap((session) => session.versions
3460
3423
  .filter((version) => version.state === "history")
3461
3424
  .map((version) => ({ session, version })));
@@ -3497,6 +3460,7 @@ window.__ModuleLoader__.load({
3497
3460
  setRestoreDialog(null);
3498
3461
  await onRestored?.(result);
3499
3462
  await load();
3463
+ await onChanged?.();
3500
3464
  const restoredId = uniqueSessionIds(result?.restored)[0] ?? current.prepared.destination?.sessionId ?? "";
3501
3465
  const title = current.session.title || t("chat.untitled");
3502
3466
  setMessage({ kind: "ok", text: isZh(t) ? `已将“${title}”恢复为新副本 ${restoredId}` : `Restored “${title}” as new copy ${restoredId}` });
@@ -3547,17 +3511,18 @@ window.__ModuleLoader__.load({
3547
3511
  : await deleteHistoryVersion(current.kind === "degraded" ? current.item.snapshotId : current.version.snapshotId);
3548
3512
  setDeleteDialog(null);
3549
3513
  await load();
3514
+ await onChanged?.();
3550
3515
  const deletedCount = Array.isArray(result?.deleted) ? result.deleted.length : 0;
3551
3516
  const skippedCount = Array.isArray(result?.skipped) ? result.skipped.length : 0;
3552
3517
  const failedCount = Array.isArray(result?.failed) ? result.failed.length : 0;
3553
3518
  if (failedCount > 0) {
3554
3519
  setMessage({ kind: "error", text: isZh(t)
3555
- ? `已删除 ${deletedCount} 个历史版本,${failedCount} 个失败`
3556
- : `Deleted ${deletedCount} history versions; ${failedCount} failed` });
3520
+ ? `已删除 ${deletedCount} 份旧版数据,${failedCount} 个失败`
3521
+ : `Deleted ${deletedCount} legacy snapshots; ${failedCount} failed` });
3557
3522
  } else {
3558
3523
  setMessage({ kind: "ok", text: isZh(t)
3559
- ? `已删除 ${deletedCount} 个历史版本${skippedCount > 0 ? `,跳过 ${skippedCount} 个受保护或不可用版本` : ""}`
3560
- : `Deleted ${deletedCount} history versions${skippedCount > 0 ? `; skipped ${skippedCount} protected or unavailable versions` : ""}` });
3524
+ ? `已删除 ${deletedCount} 份旧版数据${skippedCount > 0 ? `,跳过 ${skippedCount} 个受保护或不可用版本` : ""}`
3525
+ : `Deleted ${deletedCount} legacy snapshots${skippedCount > 0 ? `; skipped ${skippedCount} protected or unavailable versions` : ""}` });
3561
3526
  }
3562
3527
  } catch {
3563
3528
  setMessage({ kind: "error", text: t("history.deleteError") });
@@ -3567,20 +3532,20 @@ window.__ModuleLoader__.load({
3567
3532
  };
3568
3533
  const deleteBody = deleteDialog === null ? "" : deleteDialog.kind === "degraded"
3569
3534
  ? (isZh(t)
3570
- ? `该历史版本已无法读取或校验,不能再用于恢复。清除后将释放它占用的磁盘空间;原聊天不会被删除。`
3571
- : `This history version can no longer be read or verified, so it cannot be restored from. Reclaiming it frees the disk space it still occupies. The original chat is not deleted.`)
3535
+ ? `该旧版数据已无法读取或校验,不能再用于恢复。清除后将释放它占用的磁盘空间;原聊天不会被删除。`
3536
+ : `This legacy snapshot can no longer be read or verified, so it cannot be restored from. Reclaiming it frees the disk space it still occupies. The original chat is not deleted.`)
3572
3537
  : deleteDialog.kind === "one"
3573
3538
  ? (() => {
3574
3539
  const title = deleteDialog.session.title || t("chat.untitled");
3575
3540
  const createdMs = Date.parse(deleteDialog.version.createdAt);
3576
3541
  const created = Number.isFinite(createdMs) ? formatDate(t, createdMs) : deleteDialog.version.createdAt;
3577
3542
  return isZh(t)
3578
- ? `将永久删除“${title}”在 ${created} 保存的历史版本(${formatBytes(deleteDialog.version.totalBytes ?? 0)})。删除后无法恢复;原聊天不会被删除,其他历史版本不受影响。`
3579
- : `This permanently deletes the ${created} history version of “${title}” (${formatBytes(deleteDialog.version.totalBytes ?? 0)}). It cannot be recovered. The original chat is not deleted, and other history versions are unaffected.`;
3543
+ ? `将永久删除“${title}”在 ${created} 保存的旧版数据(${formatBytes(deleteDialog.version.totalBytes ?? 0)})。删除后无法恢复;原聊天不会被删除,其他旧版数据不受影响。`
3544
+ : `This permanently deletes the ${created} legacy snapshot of “${title}” (${formatBytes(deleteDialog.version.totalBytes ?? 0)}). It cannot be recovered. The original chat is not deleted, and other legacy snapshots are unaffected.`;
3580
3545
  })()
3581
3546
  : isZh(t)
3582
- ? `将永久删除 ${deleteDialog.summary.versionCount} 个历史版本,涉及 ${deleteDialog.summary.sessionCount} 个会话,预计释放 ${formatBytes(deleteDialog.summary.bytes)}。删除后无法恢复;原聊天不会被删除。回收站保护和无法读取的版本将跳过。`
3583
- : `This permanently deletes ${deleteDialog.summary.versionCount} history versions across ${deleteDialog.summary.sessionCount} chats and is expected to release ${formatBytes(deleteDialog.summary.bytes)}. They cannot be recovered. Original chats are not deleted. Recycle-protection and unreadable versions are skipped.`;
3547
+ ? `将永久删除 ${deleteDialog.summary.versionCount} 份旧版数据,涉及 ${deleteDialog.summary.sessionCount} 个会话,预计释放 ${formatBytes(deleteDialog.summary.bytes)}。删除后无法恢复;原聊天不会被删除。回收站保护和无法读取的版本将跳过。`
3548
+ : `This permanently deletes ${deleteDialog.summary.versionCount} legacy snapshots across ${deleteDialog.summary.sessionCount} chats and is expected to release ${formatBytes(deleteDialog.summary.bytes)}. They cannot be recovered. Original chats are not deleted. Recycle-protection and unreadable versions are skipped.`;
3584
3549
 
3585
3550
  return (0, jsx.jsxs)("div", { className: "dac-mode-panel dac-history-panel", children: [
3586
3551
  (0, jsx.jsxs)("div", { className: "dac-history-toolbar", children: [
@@ -4461,7 +4426,6 @@ window.__ModuleLoader__.load({
4461
4426
  }),
4462
4427
  (0, jsx.jsxs)("div", { className: "dac-tabs", role: "tablist", "aria-label": t("page.title"), children: [
4463
4428
  (0, jsx.jsx)("button", { type: "button", role: "tab", className: "dac-tab", "aria-selected": pageMode === "archived", onClick: () => setPageMode("archived"), children: t("tab.archived") }),
4464
- (0, jsx.jsx)("button", { type: "button", role: "tab", className: "dac-tab", "aria-selected": pageMode === "history", onClick: () => setPageMode("history"), children: t("tab.history") }),
4465
4429
  (0, jsx.jsx)("button", { type: "button", role: "tab", className: "dac-tab", "aria-selected": pageMode === "trash", onClick: () => setPageMode("trash"), children: t("tab.trash") }),
4466
4430
  (0, jsx.jsx)("button", { type: "button", role: "tab", className: "dac-tab", "aria-selected": pageMode === "insights", onClick: () => setPageMode("insights"), children: t("tab.insights") }),
4467
4431
  (0, jsx.jsx)("button", { type: "button", role: "tab", className: "dac-tab", "aria-selected": pageMode === "lineage", onClick: () => setPageMode("lineage"), children: t("tab.lineage") }),
@@ -4634,7 +4598,6 @@ window.__ModuleLoader__.load({
4634
4598
  onEditMetadata: openMetadataEditor
4635
4599
  }, group.key)),
4636
4600
  ] }),
4637
- pageMode === "history" && (0, jsx.jsx)(HistoryPanel, { key: `history-${workspaceArchiveRefresh}`, active: true, t, onPreview: openHistoryPreview, onRestored: historyRestored, fallbackFocusRef: pageHeadingRef }),
4638
4601
  pageMode === "trash" && (0, jsx.jsxs)("div", { className: "dac-mode-panel", children: [
4639
4602
  trash.status === "loading" && (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), (0, jsx.jsx)("span", { children: t("trash.loading") })] }),
4640
4603
  trash.status === "error" && (0, jsx.jsxs)("div", { className: "dac-center", children: [
@@ -4682,7 +4645,7 @@ window.__ModuleLoader__.load({
4682
4645
  onPreview: (row) => openPreview(row, "trash"),
4683
4646
  }, group.key)),
4684
4647
  ] }),
4685
- pageMode === "insights" && (0, jsx.jsx)(StorageRetentionPanel, { key: `insights-${workspaceArchiveRefresh}`, t }),
4648
+ pageMode === "insights" && (0, jsx.jsx)(StorageRetentionPanel, { key: `insights-${workspaceArchiveRefresh}`, t, onPreview: openHistoryPreview, onRestored: historyRestored, fallbackFocusRef: pageHeadingRef }),
4686
4649
  pageMode === "lineage" && (0, jsx.jsx)(ArchiveRelationshipsPanel, { t }),
4687
4650
  workspaceArchive?.stage === "choose" && (0, jsx.jsx)(WorkspaceArchiveChooserDialog, {
4688
4651
  t,
@@ -4778,7 +4741,6 @@ window.__ModuleLoader__.load({
4778
4741
  await workspaces?.refresh?.();
4779
4742
  },
4780
4743
  view: async () => openArchiveSettings(t),
4781
- capture: async (sessionId) => post("/history/capture", { sessionId }),
4782
4744
  });
4783
4745
  ctx.effect(() => installArchiveNoticeInterceptor(workspaces, archiveNotice), "archived-chats: archive success notice");
4784
4746
  const refreshWorkspaceArchiveConsumers = async () => {
package/lib/index.js CHANGED
@@ -875,7 +875,6 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
875
875
  sessions: ctx.get('sessions'),
876
876
  agents: ctx.get('agents'),
877
877
  inspectConversation,
878
- historyService,
879
878
  lifecycle,
880
879
  secret: workspaceArchiveSecret,
881
880
  });
@@ -1021,19 +1020,7 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1021
1020
  path: `${ROUTE_PREFIX}/history/capture`,
1022
1021
  handler: async (req, res) => {
1023
1022
  if (!guard(req, res)) return;
1024
- try {
1025
- const body = await readBody(req, 64 * 1024);
1026
- if (!exactBody(body, ['sessionId']) || typeof body.sessionId !== 'string' || body.sessionId === '') {
1027
- send(res, 400, { ok: false, error: 'history-capture-invalid' });
1028
- return;
1029
- }
1030
- const result = await historyService.captureArchived(body.sessionId);
1031
- insightsService.invalidate();
1032
- send(res, 200, { ok: true, ...result });
1033
- } catch (error) {
1034
- ctx.logger.warn(`archived-chats: history capture failed: ${String(error?.code ?? error?.name ?? 'history-capture-failed')}`);
1035
- historyError(res, error, 'history-capture-failed');
1036
- }
1023
+ send(res, 410, { ok: false, error: 'history-capture-retired' });
1037
1024
  },
1038
1025
  }), 'archived-chats: history capture route');
1039
1026
 
package/lib/retention.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from 'node:crypto';
1
+ import { createHash } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { resolve } from 'node:path';
4
4
  import { atomicWriteFile } from './durable.js';
@@ -123,16 +123,6 @@ function canonicalTime(value) {
123
123
  return parsed.valueOf();
124
124
  }
125
125
 
126
- function snapshotOrderNewest(left, right) {
127
- const time = canonicalTime(right.createdAt) - canonicalTime(left.createdAt);
128
- return time || right.snapshotId.localeCompare(left.snapshotId);
129
- }
130
-
131
- function snapshotOrderOldest(left, right) {
132
- const time = canonicalTime(left.createdAt) - canonicalTime(right.createdAt);
133
- return time || left.snapshotId.localeCompare(right.snapshotId);
134
- }
135
-
136
126
  function normalizeSnapshots(inventory) {
137
127
  if (!plainObject(inventory) || !plainObject(inventory.summary)
138
128
  || !Number.isSafeInteger(inventory.summary.snapshotBytes) || inventory.summary.snapshotBytes < 0
@@ -184,50 +174,9 @@ export function planRetention({ inventory, trashRecords, policy: inputPolicy, no
184
174
  const snapshots = normalizeSnapshots(inventory);
185
175
  const trash = normalizeTrashRecords(trashRecords);
186
176
  const candidates = [];
187
- const selectedSnapshots = new Set();
188
- const trustedSnapshotBytes = snapshots.reduce((total, item) => total + item.totalBytes, 0);
189
- let projectedSnapshotBytes = trustedSnapshotBytes;
190
-
191
- const addSnapshot = (item, reason) => {
192
- if (item.active || selectedSnapshots.has(item.snapshotId)) return;
193
- selectedSnapshots.add(item.snapshotId);
194
- projectedSnapshotBytes = Math.max(0, projectedSnapshotBytes - item.totalBytes);
195
- candidates.push({
196
- key: `snapshot:${item.snapshotId}`,
197
- action: 'delete-snapshot',
198
- reason,
199
- snapshotId: item.snapshotId,
200
- sessionId: item.sessionId,
201
- createdAt: item.createdAt,
202
- bytes: item.totalBytes,
203
- });
204
- };
205
-
206
- const bySession = new Map();
207
- for (const item of snapshots) {
208
- if (item.active) continue;
209
- const current = bySession.get(item.sessionId) ?? [];
210
- current.push(item);
211
- bySession.set(item.sessionId, current);
212
- }
213
- for (const sessionId of [...bySession.keys()].sort()) {
214
- const history = bySession.get(sessionId).sort(snapshotOrderNewest);
215
- for (const item of history.slice(policy.historicalSnapshotsPerSession)) addSnapshot(item, 'history-count');
216
- }
217
-
218
- if (policy.historicalSnapshotMaxAgeDays !== null) {
219
- const cutoff = now.valueOf() - policy.historicalSnapshotMaxAgeDays * DAY_MS;
220
- for (const item of snapshots.filter((row) => !row.active && !selectedSnapshots.has(row.snapshotId)).sort(snapshotOrderOldest)) {
221
- if (canonicalTime(item.createdAt) <= cutoff) addSnapshot(item, 'snapshot-age');
222
- }
223
- }
224
-
225
- if (policy.snapshotQuotaBytes !== null && projectedSnapshotBytes > policy.snapshotQuotaBytes) {
226
- for (const item of snapshots.filter((row) => !row.active && !selectedSnapshots.has(row.snapshotId)).sort(snapshotOrderOldest)) {
227
- if (projectedSnapshotBytes <= policy.snapshotQuotaBytes) break;
228
- addSnapshot(item, 'snapshot-quota');
229
- }
230
- }
177
+ // Keep the legacy policy schema readable, but never use its retired rules to
178
+ // select existing snapshots for deletion. Migration is an explicit user action.
179
+ const projectedSnapshotBytes = snapshots.reduce((total, item) => total + item.totalBytes, 0);
231
180
 
232
181
  if (policy.recycleMaxAgeDays !== null) {
233
182
  const cutoff = now.valueOf() - policy.recycleMaxAgeDays * DAY_MS;
@@ -49,13 +49,12 @@ function stableNow(now) {
49
49
  return value;
50
50
  }
51
51
 
52
- export function createWorkspaceBulkArchiveService({ registry, sessions, agents, inspectConversation, historyService, lifecycle, now = () => new Date(), secret } = {}) {
52
+ export function createWorkspaceBulkArchiveService({ registry, sessions, agents, inspectConversation, lifecycle, now = () => new Date(), secret } = {}) {
53
53
  if (registry === null || typeof registry !== 'object'
54
54
  || typeof registry.list !== 'function' || typeof registry.archiveSession !== 'function'
55
55
  || !isArchivedIdCollection(registry.archivedSessionIds)
56
56
  || sessions === null || typeof sessions !== 'object' || typeof sessions.get !== 'function'
57
57
  || typeof inspectConversation !== 'function'
58
- || historyService === null || typeof historyService !== 'object' || typeof historyService.captureArchived !== 'function'
59
58
  || lifecycle === null || typeof lifecycle !== 'object' || typeof lifecycle.run !== 'function'
60
59
  || typeof now !== 'function' || (typeof secret !== 'string' && !Buffer.isBuffer(secret))
61
60
  || (typeof secret === 'string' && secret === '')) {
@@ -225,7 +224,7 @@ export function createWorkspaceBulkArchiveService({ registry, sessions, agents,
225
224
  snapshots: [],
226
225
  };
227
226
  for (const sessionId of confirmation.sessionIds) {
228
- const item = { archived: null, skipped: null, failed: null, snapshot: null };
227
+ const item = { archived: null, skipped: null, failed: null };
229
228
  try {
230
229
  await lifecycle.run(async () => {
231
230
  const workspace = workspaceFor(confirmation.workspace.id);
@@ -257,16 +256,9 @@ export function createWorkspaceBulkArchiveService({ registry, sessions, agents,
257
256
  return;
258
257
  }
259
258
  item.archived = sessionId;
260
- try {
261
- await historyService.captureArchived(sessionId, { lockHeld: true });
262
- item.snapshot = { id: sessionId, status: 'captured' };
263
- } catch {
264
- item.snapshot = { id: sessionId, status: 'snapshot-failed' };
265
- }
266
259
  });
267
260
  if (item.archived !== null) {
268
261
  result.archived.push(item.archived);
269
- result.snapshots.push(item.snapshot);
270
262
  } else if (item.skipped !== null) result.skipped.push(item.skipped);
271
263
  else if (item.failed !== null) result.failed.push(item.failed);
272
264
  else result.failed.push({ id: sessionId, reason: 'lifecycle-failed' });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "1.1.2",
4
- "description": "DeepSeek Harness 会话档案:从设置中安全批量归档工作区会话,按工作区浏览和全文搜索归档聊天,原生只读预览对话、工具活动和已存储图片,管理标签备注与 ZIP 备份恢复;保留历史版本并恢复为副本,提供可撤销回收站、空间分账、保留策略和来源与分支。所有数据留在本机。 Session Archive for DeepSeek Harness: safely bulk-archive workspace chats from Settings; browse and full-text search archived chats; natively preview conversations, tool activity, and stored images read-only; manage tags, notes, and ZIP backup restore; retain local History with restore-as-copy, an undoable Recycle Bin, storage accounting, retention policies, and Origins & Branches. All data stays local.",
3
+ "version": "1.2.0",
4
+ "description": "DeepSeek Harness 会话档案:从设置中安全批量归档工作区会话,按工作区浏览和全文搜索归档聊天,原生只读预览对话、工具活动和已存储图片,管理标签备注与 ZIP 备份恢复;提供可撤销回收站、空间分账、回收站保留策略和来源与分支,保留旧版数据恢复入口。所有数据留在本机。 Session Archive for DeepSeek Harness: safely bulk-archive workspace chats from Settings; browse and full-text search archived chats; natively preview conversations, tool activity, and stored images read-only; manage tags, notes, and ZIP backup restore; provide an undoable Recycle Bin, storage accounting, Recycle Bin retention policies, Origins & Branches, and access to legacy recovery data. All data stays local.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",
7
7
  "repository": {
@@ -21,9 +21,6 @@
21
21
  "recycle-bin",
22
22
  "undo",
23
23
  "snapshot",
24
- "history",
25
- "time-machine",
26
- "version-restore",
27
24
  "retention",
28
25
  "storage-insights",
29
26
  "session-lineage",