# dsh-plugin-archived-conversations 设计文档 为 DeepSeek Harness 设计的插件:在**设置界面**管理**已归档对话**,提供 **取消归档**与**永久删除**两个操作。 ## 1. 背景与现状 深入研读了 DSH core 0.1.2-rc.1(Desktop 2.0.5 集成环境)的核心实现后确认 (初稿基线为 alpha.1 / Desktop 2.0.4): - **归档能力已内建于工作区域(`dsh-workspace`)**:`WorkspaceRegistry` 持有 registry 级持久状态 `archivedSessionIds`(`workspace.json` 的全局 状态),`archiveSession(id)` 将其加入集合。侧边栏会话行菜单( `dsh-client-ui-workspace`)已有「归档会话」入口,通过 `remote.workspace.archiveSession` 调用;会话列表的所有分组投影都会 过滤掉已归档 id(`sessionVisible` 检查 `archived.has(id)`)。 - **缺口一:没有取消归档。** 核心代码里只有 `archiveSession`,没有 `unarchiveSession`;归档后侧边栏里无法再找到该对话。 - **缺口二:没有删除。** `dsh-session` 的 `SessionStore` 只有 `enter()` 返回的 detach disposer(归属创建者 fiber),没有按 id 的 公开移除 API;`session-persistence-jsonl` 后端也没有删除接口;会话 控制器远程 API(`session/*`)不含 delete。 - **因此归档成了单向操作**——用户只能把对话藏起来,既不能恢复,也不能 清除。本插件补上这两个缺失的半程,并把入口放在设置界面。 ## 2. 架构总览 ``` ┌────────────────────────────────────────────────────────────────┐ │ 宿主 (host) │ │ lib/index.js ctx.plugin(ArchivedSessionsRemote) │ │ lib/typert.js ./typert 导出 → dsh-typert-loader 自动注册 │ │ lib/remote.js ArchivedSessionsRemote (TypertRemoteService) │ │ 注入: workspaceRegistry / sessionPersistence / sessions / │ │ sessionProjections / sessionProjectionCache │ │ 方法: list · archive · unarchive · deleteSession │ │ │ │ 关键交互: │ │ · 取消归档 → registry.enqueueOperation + setState │ │ (workspace.json 全局态) → storage-domain 发 domain/changed │ │ → workspace feed 广播 {type:"archived"} 增量 → 客户端 │ │ workspaces 存储更新 → 侧边栏恢复该对话 │ │ · 删除 → flush(公开) → detachLiveSession(受守卫的逃生通道) │ │ → rm 会话目录 → registry 清理(归档集合 + 工作区 sessionIds) │ └────────────────────────────────────────────────────────────────┘ ▲ Typert 网关 (strict + SRC 双保险) ┌────────────────────────────────────────────────────────────────┐ │ 客户端 (client) │ │ client/index.js $mount(TYPERT_REMOTE) → remote.archivedSessions│ │ + settings.section 槽位注册 │ │ client/typert.js 严格 zod wire codec(与宿主清单同构) │ │ client/controller.js 页面快照存储 + 动作 + 归档集合实时订阅 │ │ client/page.js React 页面(行操作 + RiskConfirmation 确认) │ │ 注入: remote / slots / locale / workspaces │ │ · 订阅客户端 workspaces 服务 → 侧边栏归档动作实时反映到本页 │ └────────────────────────────────────────────────────────────────┘ ``` ## 3. 宿主端设计 ### 3.1 远程命名空间 `archivedSessions` 采用 Typert 协议(与全部官方 `dsh-api-*` 一致): - `ArchivedSessionsRemote extends TypertRemoteService`,服务键 `archivedSessionsRemote`,wire 命名空间 `archivedSessions`。 - **宿主清单**通过 `package.json` 的 `exports["./typert"]` 提供,由 `@deepseek-ai/dsh-typert-loader` 在本插件 loader entry 挂载时自动 注册进 `ctx.typert`(卸载时自动撤回)——这是官方为第三方插件预留的 路径(见 `dsh-typert-loader` 文档:`./typert` 导出的 `TYPERT` 清单)。 - 清单含 zod v4 严格 codec + 完整 FaceModel(services/members/types), 全部通过 `dsh-typert-loader` 的校验规则。 - 网关解析顺序:先查严格描述符(本插件注册了),缺失时回落到 SRC 模式 (`typertRemote` 绑定 + 方法签名推导)——双保险,即使某次升级收紧了 清单校验,服务仍可被调用。 ### 3.2 方法 | 方法 | 参数 | 行为 | | --- | --- | --- | | `list(signal)` | — | 按归档顺序倒序(最近归档在前)列出全部已归档对话。每行:标题、cwd、创建/最近活动时间、是否仍在内存中、读取错误(如有)。 | | `archive(request, signal)` | `{sessionId}` | 委托 `workspaceRegistry.archiveSession`(保持与核心归档路径完全一致)。 | | `unarchive(request, signal)` | `{sessionId}` | 在 `enqueueOperation` 串行链中从 `archivedSessionIds` 移除该 id(幂等)。 | | `deleteSession(request, signal)` | `{sessionId}` | 见 3.3。 | ### 3.3 行投影(零 I/O 阶梯) 与 `api-session-controller` 的 `summarizeCold` 同构: - **live 会话**:`sessionProjections.cachedSnapshot(session)`; - **冷会话**:`sessionPersistence` 头(`registry.readSessionHeader`)+ `sessionProjectionCache.cachedSnapshot(header)`(`session_projcache` 持久 投影缓存,按 `{createdAt, cwd}` 身份校验,绝不读错生命周期)。 读不到的坏行**不失败整页**:返回 `readError` 字段,页面仍可对该行执行 删除(自愈:删除时归档集合清理照常进行)。 ### 3.4 删除顺序(关键) 删除一个已归档会话,顺序严格为: 1. **校验**:id 必须在 `archivedSessionIds` 中,否则 `not-archived`。 2. **flush**:若会话 live,先 `SessionStore.flush(session)`(公开 API), 把缓冲事件全部落盘——这一步保证随后删文件不会丢失数据、也不会被 滞留的写后批次重新物化。 3. **detach**:`SessionStore` 没有按 id 的公开移除面,因此使用受守卫的 逃生通道——`sessions.store`(公开 Map)+ `detachEntered(entry)` (公开方法)。detach 触发 `session/disposed`,驱动持久化层退休排空 (此时已无待写内容,为空操作)。若未来版本收窄该面(特性检测 `typeof detachEntered === "function" && store instanceof Map`), 抛 `live-detach-unsupported` 业务错误,绝不下坏手。 4. **删文件**:`sessionPersistence.locate(header)`(jsonl 后端公开的 路径解析)得到 `session.jsonl.zstd` 路径,递归删除其目录。 后端无 `locate` 时抛 `unsupported-backend`。 5. **registry 清理**(一次 `enqueueOperation` 内,与其它工作区写串行): - `setState` 从 `archivedSessionIds` 移除; - 遍历 `registry.list()`,命中 `entity.sessionIds` 的实体调用 `entity.detachSession(id)`(工作区记账清理)。 - `session_projcache` 无需清理:投影缓存行带身份校验,孤儿行天然 失效("possibly stale but never wrong")。 域写经由 `domain.global.set` → 广播 `domain/changed` → 工作区 feed 重投影 → 客户端列表/侧边栏自动消失,无需插件再通知任何一方。 ### 3.5 错误语义 业务失败统一 `new RemoteError(code, message, details)`(dsh-typert-protocol rc 线起取代旧名 `TypertRemoteFailure`;判别一律走结构标记 `remoteErrorOf`,不做 `instanceof`),客户端收到 `{ok:false, error:{code, message, details}}`: - `not-archived` —— 目标不在归档集合; - `live-detach-unsupported` —— 构建版本收紧了内部面,建议稍后重试; - `unsupported-backend` —— 持久化后端不暴露会话产物路径。 ## 4. 客户端设计 ### 4.1 命名空间挂载 客户端 `apply` 中 `await ctx.remote.$mount(TYPERT_REMOTE)`,把 `remote.archivedSessions` 服务装到客户端根上下文。注意**本插件自身不 能在 `inject` 里声明 `remote.archivedSessions`**(那是它自己创建的, 声明会永久停驻等待自己)——官方 `dsh-api-remotes` 的挂载者同样是只注入 `remote`。挂载完成后用 `ctx.get("remote.archivedSessions")` 惰性取用。 方法名避开命名空间服务保留名(`ctx/empty/invokeRemote/methods/name/ namespace/has/install/installDirect/installScoped/remove`),故删除方法 命名 `deleteSession` 而非 `remove`。 ### 4.2 设置界面入口 注册 `settings.section` 槽位(list 槽,选项 `id/order/label`,inject 面 传 `page` 控制器与 `t`): - `id: "archived-conversations"`、`order: 30`(位于「插件」与市场页之间); - 中英双语字典(`locale.register(NS, {zh, en})`,键集完全对齐)。 ### 4.3 页面 - `useSyncExternalStore` 消费控制器快照:`{phase, items, archivedSessionIds, message, pending}`。 - 每行:标题(缺省「未命名对话」)、工作区路径、最近活动相对时间 (复用 `relativeTime` + 本插件字典)、读取错误徽标;行操作: **取消归档**、**删除**。 - 删除走 `RiskConfirmation`(警告 + 勾选「我明白此操作不可恢复」才可 确认),确认后调用 `deleteSession`。 - 头部提供「全部取消归档」批量操作(客户端顺序调用,结束统一刷新)。 - 空态 / 加载态 / 错误态(带重试);操作结果用 5 秒自动消退的横幅反馈。 ### 4.4 实时性 - **出方向**(取消归档 → 侧边栏):宿主 registry 写 → `domain/changed` → 工作区 feed `{type:"archived"}` 增量 → 客户端 `workspaces` 存储 更新 → 侧边栏会话树立即恢复该行。零额外代码。 - **入方向**(侧边栏归档 → 本页):页面控制器订阅客户端 `workspaces` 服务的 `list` 快照(`getSnapshot().archivedSessionIds`),集合变化即 重新拉取明细。为此客户端 `inject` 声明 `workspaces` 并确保 `@deepseek-ai/dsh-api-workspace-controller` 参与客户端组合。 ## 5. 包结构与安装 ``` package.json # exports: "." / "./typert" / "./client";dsh.bundle.patch + dsh.client.inject cordis.patch.yml # bundle 补丁:insert 条目 {id: archived-conversations, name: 本包} lib/index.js # 宿主入口 apply(ctx) lib/typert.js # 宿主 TYPERT 清单(zod codec + FaceModel) lib/remote.js # ArchivedSessionsRemote client/index.js # 客户端入口($mount + 槽位注册) client/typert.js # 客户端 TYPERT_REMOTE client/controller.js # 页面控制器 client/page.js # React 设置页面 client/locale.js # zh/en 字典 client/styles.js # 注入式样式(dshAcv- 前缀) ``` 安装方式与官方第三方插件一致,**两步缺一不可**: 1. 把本包加入 profile 依赖(桌面端 `~/.dsh/profiles/desktop` 下 `pnpm add <本包路径>`,或经插件市场安装到 npm 包); 2. 把包名加入 profile 清单的 `dsh.profile.bundles`——DSH 的 loader 条目 全部来自 bundle 补丁层(`dsh.bundle.patch` 的 insert 行)与 profile 自身的 `cordis.patch.yml` 用户层,**依赖本身不会自动成为条目**(这是 首次安装只做了第 1 步导致设置页没有入口的直接原因)。 重启后生效。`dsh.client.inject` 声明客户端组合所需的官方包; `dsh-typert-loader` 在 entry 挂载时自动注册宿主清单。 ## 6. 风险与取舍 - **受守卫的内部面**:`detachEntered`/`store` 是编译产物中可达但未进入 契约的表面,已特性检测 + 明确业务错误兜底;这是插件能在无公开删除 API 的核心上完成「删除已打开会话」的唯一路径。 - **删除运行中会话**:归档集合里的会话一般已结束;若 agent 仍在运行, 删除后事件仅不再持久化/广播,循环在内存中继续(不会崩溃)。页面文案 已强调不可恢复。 - **归档时间**:核心只保存 id 数组、无时间戳;页面如实展示「最近活动 时间」而非杜撰「归档时间」。 - **客户端双刷新**:本页动作与 workspaces 订阅可能各触发一次刷新, 幂等无害。 - 插件不重写核心包;升级 DSH 后若内部面变化,`detachLiveSession` 会 优雅降级为明确错误而非损坏数据。