# 数据目录 插件自己的全部状态都落在一个目录里。这个目录**不是** `~/.dsh` 的子目录,而是它的兄弟: ``` C:\Users\\.dsh ← DSH 自己的 C:\Users\\.dsh-session-navigator ← 本插件的 ``` 插件早期叫 `dsh-session-explorer`,那时用的目录名是 `.dsh-session-explorer`。启动时如果新名字的 目录还不存在、旧名字的存在,插件会把旧目录**改名**过来(`lib/paths.js` 的 `defaultDataDir()`), 文件夹、标签、回收站 payload 一起带走;改名失败(比如另一个 DSH 进程还开着它)就继续用旧目录, 下次启动再试。两个目录同时存在时用新目录,旧目录原样保留。 两个理由(`lib/paths.js:1-26`): 1. 回收站里装的是**整个会话目录**,几百 MB。塞进 `~/.dsh` 会让备份、迁移、清理的归属变得模糊。 2. 一个环境变量覆盖让自测指向一次性目录,测试永远碰不到真实数据。 ## 1. 目录内容 ``` / ├── tree.v1.json 文件夹、放置、workspace 顺序、快捷方式、置顶、快速访问 ├── annotations.v1.json 标签、备注、收藏/待办标记、共享标签库 ├── settings.v1.json 跨浏览器的偏好(布局在 localStorage) ├── summaries.v1.json AI 写出的标题/描述/标签,与累计花费的 token 账本(见 §1.1) ├── move-journal.v1.json 正在进行的跨工作区移动(见 §6.2) ├── content-index.v1.json 会话内容索引的缓存(派生文件,删了只是要重建一次) ├── .location 迁移后留下的指针(只有 copy 模式会留) ├── .tmp/ 原子写的临时文件,启动时只清 `*.tmp` ├── quarantine/ 读不出来的文档原件(`.corrupt-.json`) ├── trash/ │ ├── index.v1.json 回收站索引 │ └── / 被删会话的 payload(整个会话目录) ├── component/ 可选的 Electron 运行时(见 component.md) │ ├── state.v1.json 生命周期记录(phase、来源、借用路径) │ └── runtime-/ 解压后的那份 Electron,装一次不可变 └── detached/ ├── window.json 独立窗口的几何与控制端口 ├── helper-app/ 本插件的两文件应用;借用别人的运行时时,它在这里而不在那边 └── chromium// helper 专用 Chromium profile ``` 借来的运行时**不在**这棵树下(见 component.md §8):它是别的插件的安装,`component/state.v1.json` 里只记一条绝对路径。所以迁移数据目录搬不走它、也不需要搬——重新扫一次就能指回去。 `detached/chromium/` 的 `` 是 `sha256(dataDir)` 的前 16 位十六进制 (`lib/detached/launcher.js:40`):必须给 helper 一个专属的 `--user-data-dir`, 和主应用共用 profile 会让两边的 Chromium 状态互相踩。 **侧车只存 DSH 不知道的东西。** 标题、cwd、时间戳、回合数、归档集、置顶集每次请求都从 DSH 读, 侧车删掉也不会丢会话——这是 `lib/store/documents.js:1-17` 写明的设计约束(源文档 S8)。 `summaries.v1.json` 是这条规则唯一要解释的地方:摘要既不是 DSH 的数据,也不是派生缓存—— 它是花过钱换来的文字,删掉就要再花一次钱,所以它按文档对待(坏文件进 `quarantine/`,不当缓存重建)。 ## 1.1 `summaries.v1.json` 一件商品是三个字段,不是一段文字:**标题、描述、标签**(用户 m01823:「总结就是让你总结标题 描述 标签」)。 三个字段各自独立——可以只要标题,也可以只要标签;没被要的字段既不出现在提示词的契约里,也不会覆盖已存的值。 ```jsonc { "sessions": { // 以会话 id 为键,与会话目录无关:会话搬了字段跟着走 "": { "title": "修复导出丢字段", // 最多 TITLE_MAX_LENGTH = 30 字符;只是建议,不自动改会话名 "description": "…", // 最多 DESCRIPTION_MAX_LENGTH = 120 字符,1-3 句 "tags": ["导出", "会话"], // 最多 TAG_LIMIT = 6 个,每个最多 TAG_MAX_LENGTH = 8 字符 "at": 1793000000000, // 写成的时间 "sourceAt": 1792990000000, // 那段对话最后活动的时间,用来看出这组字段有多旧 "model": { "provider": "wb-proxy", "model": "deepseek-v4.1-flash", "effort": "low" }, "tokens": { "inputTokens": 12000, "outputTokens": 900 }, "turns": 42, // 送进模型的对话条数(不是会话的全部回合) "applied": { "title": false, "description": false, "tags": false } // 用户点过「用这个标题」/「并入我的备注」/ // 「合并所选」没有;只有点击才改会话名、备注或标签库, // 记录本身不自动应用 } }, "spend": { // 累计花费:这一台机器上所有生成尝试的总和 "inputTokens": 0, "outputTokens": 0, "totalTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "runs": 0, // 每次尝试都算,失败的也算(失败同样按 token 计费) "failures": 0 } } ``` 三个字段全空的记录读盘时就丢弃(`normalizeSummaryEntry()` 返回 `null`);旧版本写过 `text` 字段的记录也 按同一条丢掉——它不属于这三个字段,留着会让这个会话看起来"已经有东西了",于是永远不会重新生成。 超长的字段是**裁剪**而不是整条拒绝:标题截断到 30、描述到 120、标签先截到 8 个字**再**去重(两个不同的长 标签截到相同的八个字,就是同一个标签,两个都留会在筛选行里出现两条一模一样的)。裁剪后为空、或整条没有 任何字段时才算失败。 「一键补齐」只看**描述与标签**(用户 m01898:「只检查描述和标签,仅仅手改的名字不算」)。任一半满足即可: 描述来自 AI 记录或用户自己写的备注,标签来自 AI 记录或用户手动打的标签。标题不参与判定——名字说明不了 对话里发生了什么。 `spend` 记的是 token,不是钱:`lib/host/summarize.js:recordSpend()` 只把 provider 回来的 `usage` 累加,provider 没给 `totalTokens` 时用 `input + output` 顶上。没有任何汇率或单价表—— 那需要跟着 provider 改价,而这个文件写在用户机器上。失败的尝试也记 `runs`:只统计成功会让 累计数看起来像"总花费",而它其实只是成功那部分的下限。 ## 2. 定位顺序(三级回退) `resolveDataDir()`(`lib/paths.js:293`)按顺序: | 优先级 | 来源 | 场景 | |---|---|---| | 1 | `DSH_SE_DATA_DIR` | 测试、显式覆盖 | | 2 | `<默认>/settings.v1.json` 的 `dataDir.current` | 一次已完成的迁移 | | 3 | `<默认>` 目录本身 | 首次运行 | `<默认>` 里的 `.location` 指针会被跟随(最多 8 跳,防止手改出的环卡住启动)。 这就是 `copy` 模式迁移后旧树还在磁盘上、下次启动不会读到旧副本的原因。 `DSH_SE_DATA_DIR` 是**校验过的**:解析结果若是文件系统根(`/`、`C:\`)、`~/.dsh` 本身或其内部, 或者反过来**包含** `~/.dsh`(家目录就是最常见的误填),都忽略并警告,回落默认值。 理由写在 `lib/paths.js:isUsableDataDir`:清空回收站会递归删掉数据目录下的 payload, 一个能包含 DSH 数据的数据目录会把"清空回收站"变成"删掉 DSH"。 同一条规则用在四个入口上:`DSH_SE_DATA_DIR`、已记录的 `dataDir.current`(`settings.v1.json` 是磁盘上的普通 JSON,手改过的值不能直接信)、`POST /datadir/migrate` 的 body、以及迁移目标目录。 ## 3. 四份侧车文档 `lib/store/documents.js:29` 的 `openDocuments()` 一次打开五份。每份的初始形状: | 文件 | 初始形状 | 说明 | |---|---|---| | `tree.v1.json` | `{ folders, placement, workspaceOrder, shortcuts, pins, quickAccess, sessionPlacement }` | `placement` 是 workspace 的显式顺序;`sessionPlacement` 是会话的**显示**归属覆盖;`pins` 是排名、`quickAccess` 是书签带,两张表故意分开(见 tree-and-placement.md §8) | | `annotations.v1.json` | `{ sessions: {}, tags: [] }` | `tags` 是共享标签库;`sessions[id]` 是单条注解 | | `settings.v1.json` | `defaultSettings()` | 形状的唯一出处是 `lib/domain/settings.js:123` | | `summaries.v1.json` | `{ sessions: {}, spend: emptySpend() }` | 见 §1.1 | | `trash/index.v1.json` | `{ entries: {} }` | 见第 5 节 | `settings.v1.json` 的完整形状: ```jsonc { "shell": { "open": { "mode": "panel" }, // panel | detached "modal": { "sizing": "fixed", // fixed | percent "size": "md", // sm | md | lg | xl "percent": "p70", // p50 | p70 | p85 | p100 "dim": 0, // 0 – 0.8,默认无遮罩 "closeOnBackdrop": true }, "window": { "alwaysOnTop": false, "bounds": null } // 独立窗口记住的几何 }, "list": { "autoCollapse": false, "showArchived": true, "collapseSubagents": true, // 折叠子智能体:开关,默认折叠 "hideBlank": true, // 过滤空会话:开关,默认过滤 "dateFormat": "date", // date | relative,详细日期 vs 距今时间 "sizeFormat": "auto" }, // auto | kb,自动转换 vs 固定 KB "search": { "indexIdle": true }, // 空闲时自动建立索引:唯一的搜索索引开关,默认开 "summary": { // AI 字段的模型与要生成哪几项 "provider": "", // 空串 = 让宿主挑(第一个 provider) "model": "", // 空串 = 该 provider 的第一个模型 "effort": "", // 适配器自己定义的词(low/high…),空串 = 用模型默认 "fields": { "title": true, "description": true, "tags": true } }, // fields 是"生成哪几项",不是"送什么":对话正文总是送, // 思考过程与工具输出从不送 "dataDir": { "current": null } } ``` 三条硬约束写在这个文件里:`dim` 默认 0 且上限 `MAX_DIM = 0.8`(面板在壁纸上要看得清); 归档是视觉降权而不是隐藏,所以 `showArchived` 默认 `true`; 默认打开方式是浮窗,因为它不会失败——独立窗口依赖可能没装的组件。 `collapseSubagents` 以前是 `subagents: "collapsed" | "expanded"` 两选一,现在是一个开关: 两种状态本来就是全部选择,用词表把它做成模式只是让一个二元偏好看起来更复杂。旧文档里的 `subagents` 仍然被读:`expanded` 迁成 `false`,两者同时存在时以开关为准。 `dateFormat` 与 `sizeFormat` 是列表里那两列怎么写:`date` 写具体时刻,`relative` 写「3 天前」 (单位随距离走:分钟 → 小时 → 天,走到天就停——不出现月与年,也不出现「昨天」这类自然语言, 数字永远在,语言跟着面板当前的语言);`auto` 在 B / KB / MB / GB 里挑合适的单位,`kb` 一列只用一个单位,方便竖着比大小。 两者的默认值都等于改造之前的样子,所以升级不会改变任何人的列表。 `indexIdle` 的默认值从 `false` 改成 `true`(m00001 第 3 条):不打开这个开关,搜索就永远停在「没索引」, 用户得先撞一次空结果才知道有这个东西。空闲索引本来就有上限——一次只做一个会话、给真请求让路、 15 秒复查一次——所以默认开不会把机器拖住;不想用它的人关掉即可,关机前那点残留写入由 `nudge()` 立刻收尾。 `mergeSettings()` 只认已知键,未知键在归一化时被丢掉,而不是落盘等下个版本困惑。 `search` 现在只剩 `indexIdle` 一个键:1.0.6 的 `indexCache` 控制的那份派生缓存已经恒开(见 §3.1)。 ### 3.1 派生的那份不是侧车:`content-index.v1.json` 四份侧车之外还有一份**可删**的文件,装的是会话内容索引的缓存:`{ version: 2, savedAt, sessions }`, `sessions[id]` 带 `sig`(`path\u0000sizeBytes\u0000activityAt`)与 `text`。它和侧车的区别是 "删了只损失时间":下次打开会话内容搜索时重读日志重建一遍,答案一模一样。`version` 一升旧文件就 被忽略并就地覆盖(文件名里的 `.v1` 是槽位、不是版本,改名只会让旧文件变成垃圾):v1 装的是 "只有用户那一半"的会话文本,留着它就会一直回答"没搜到"而信号又永不变化。所以 - 它不进 `DOCUMENT_NAMES`,没有 revision、没有乐观锁,写入走同一个 `writeAtomic`。 - 它**没有开关**(1.0.6 的 `search.indexCache` 已被 `normalizeSettings` 当未知键丢掉):派生文件删掉 只花一次重建,而"不写缓存"只会让每次启动都重读一百多 MB 日志。删它的入口是设置页的「删除索引缓存」、 搜索范围弹窗里跳过去的那一组,以及「清除数据」。 - `version` 不匹配就当没有(不回写迁移);文件坏了、临时目录不可写,搜索照常工作,只是每次重读。 - 「清除数据」也会删它,并且不把它算进四份侧车那份清单里。 **`sig` 是变更检测的全部依据。** 三个字段里有两个随对话增长而变(`sizeBytes`、`activityAt`),所以 「内容变了」是一个可比对的事实,不是「mtime 看着不对」。空闲索引每一轮复查都重算 `sig`:对不上的 会话重读日志重建,对得上的原样留在内存里(`lib/sources/content.js` 的 `sigOf`)。这就是 `indexIdle` 默认为开后还安全的原因——一次正在进行的对话不会让整份索引失效,只会让那一个会话在下一次复查 (`recheckDelayMs`,默认 15 秒)里更新,而且复查可以被 `nudge()` 提前成真跑。 ## 4. 原子写与乐观锁 `lib/store/sidecar.js` 保证两件事: - **不撕裂**:写临时文件(同卷的 `.tmp/`)→ flush → `rename` 覆盖目标。崩溃只会留下旧文件, 不会留下半个 JSON。 - **不互相覆盖**:每次成功写入 `revision += 1`。客户端带着它看到的 revision 来, 不匹配就 `revision-conflict`,而不是静默覆盖别人刚做的改动。 读损坏(不是 JSON 对象)时文件被改名成 `.corrupt-.json` 移进 `quarantine/`, 然后以空文档重建——**不会**因为一个坏文件让插件起不来。 放在 `quarantine/` 而不是 `.tmp/`:`.tmp/` 每次启动都会清空,那份"留着给人手工恢复"的原件 会被自己删掉。启动时只清 `.tmp/` 下的 `*.tmp`(原子写的残留),`*.corrupt-*` 会被搬进 `quarantine/`。 ## 5. 回收站 `lib/host/trash.js` 做的就是文件管理器做的事: | 操作 | 动作 | |---|---| | 删除 | `rename ///` → `/trash//` | | 恢复 | rename 回去 | | 清空 | `rm -rf` payload 目录 | 跨卷时 rename 退化成 copy-then-delete,其余流程不变。 **索引条目**(`lib/domain/trash.js:95`): ```jsonc { "sessionId": "01J…", "trashId": "k3f9ab12", "title": "…", "cwd": "…", "workspaceId": "…", "workspaceTitle": "…", "sizeBytes": 1234567, "createdAt": 1730000000000, "originalLogPath": "///session.jsonl", "deletedAt": 1730000000000, "pending": true // 只在写入过程中短暂存在 } ``` `title/cwd/workspaceId/…` 是**快照**而不是镜像:payload 已经不可读了,这几项描述的是它进回收站 那一刻的样子。 **崩溃安全**:索引条目在 payload 移动**之前**写入并标记 `pending`。两者之间崩溃只会留下 "pending 但 payload 还在原地"——可对账,不是丢会话。下次启动的 `reconcileTrash` 收尾: payload 已经到位 → 升级为 ready;payload 从未离开 → 删掉这条记录。 **认领**:写 `pending` 这一步同时充当互斥锁。两个窗口同时删同一会话时,后到者在文档锁里看见 条目已存在就放弃(记 `skipped`),不会各生成一个 trashId——否则后到者的补偿会删掉先到者的 条目,留下一个没有索引指向的 payload,既恢复不了也清不掉(`lib/host/trash.js:218-234`)。 **恢复落点**(`projectDirFor()`,`lib/host/trash.js:255`)是三级阶梯: 1. 记录里的原项目目录,不存在就重建; 2. 同一 workspace 下还有会话的目录(让恢复的会话落在邻居旁边); 3. 任何一个活的项目目录。 第 3 级就是"未分组"能当安全落点的原因:workspace 成员关系存在 DSH 的 registry 里, 一个不再属于任何 workspace 的 id 自动出现在未分组。 清空回收站时,指向已删会话的快捷方式会一并删除——引用不能活得比目标久。 **清空回收站 ≠ 清除数据。** 前者只处理回收站里的 payload(一次一个会话,可恢复的那部分仍然是 活的),后者是设置页「危险操作」里那一个按钮:四份侧车文档(含虚拟树 `tree.v1.json`)、`trash/` 里剩下的全部 payload、 `quarantine/` 与 `.tmp/` 一起清空(目录本身留下),删完 `invalidateMemory()` 抖掉面板的 memo。它不可逆,所以 host 先验用户打的字是不是 `DELETE`,再过一遍 `isUsableDataDir()`; 浏览器组件不在其中——那是安装,不是用户数据。详见 `docs/api.md` §4.12。 ## 6. 迁移 ### 6.1 数据目录迁移 `POST /datadir/migrate`(`lib/host/datadir.js:189`)按顺序做七件事,每一步都推进 `steps` 数组: | # | 步骤 | 说明 | |---|---|---| | 1 | 预检 | 复用 `inspect` 的全部校验:绝对路径、非根、非当前目录、可写、目标不含另一套安装 | | 2 | `api.migration` 上锁 | 期间所有非 GET 请求返回 `409 migration-in-progress` | | 3 | `copied` | `cp` 递归复制(同卷 `move` 也用 `cp`:它保留时间戳、源缺失会失败) | | 4 | `verified` | 校验目标可读、内容齐全 | | 5 | `reopened` | 关闭并重开读端,指向新目录 | | 6 | `recorded` | 把 `dataDir.current` 写进**新目录**的 `settings.v1.json` | | 7 | `pointer` / `pruned` / `removed` | `copy` 模式在旧目录留 `.location` 指针并清空其余内容(`pruned`);`move` 模式直接删旧目录(`removed`) | 任何一步失败都走同一个回滚(`lib/host/datadir.js:204` 的 `rollbackCopy`):重开旧目录、失效缓存, 再**只删这次复制写进去的东西**——目标目录原本不存在就整个删掉,原本存在(空目录)就只删 `carry` 写过的那些顶层条目。回滚不会删用户自己放进去的文件。 **旧目录在成功之前从不被碰**,所以一次失败的迁移只损失那次复制。 `mode` 不是用户选的,是**同卷性**决定的:同卷 `move`,跨卷 `copy`。 `move` 是唯一会删旧目录的模式。 `datadir-not-empty` 有两个来源(`lib/host/datadir.js:146-155`): - `OCCUPANCY_MARKERS = ["tree.v1.json", "trash/index.v1.json"]` 在目标里出现,说明那里是另一套安装; - 目标目录存在且**非空**。迁移是往目标里复制、失败时按 `carry` 写过的条目回滚,所以目标必须从空开始; 否则要么两套数据混在一起,要么回滚时误删用户的文件。 ## 7. 跨工作区移动的意图日志 一次移动是两次写:先把 payload 挪到目标 project 目录,再让 workspace 注册表知道这件事 (`lib/host/move.js` 文件头)。中间崩溃会留下"字节在目标、注册表还指着旧家"的状态——会话 在两边都看不到,正是回收站用 `pending` + `reconcileTrash` 挡掉的那种情况。 `move-journal.v1.json`(`lib/host/move-journal.js`)是移动版的同一道保险: ```json { "version": 1, "moves": [{ "sessionId": "…", "from": "…", "to": "…", "logName": "…", "workspaceId": "…", "header": { "id": "…", "cwd": "…" }, "startedAt": 1760000000000 }] } ``` - payload 离开原目录**之前**写进一条,注册表跟上之后删掉;写文件是 `temp + rename`,崩溃只会留下旧日志或新日志。 - 启动时 `reconcileMoves()`(`lib/host/move.js`,`lib/index.js` 里紧挨 `reconcileTrash` 调用)读剩下的条目: 目标目录在 → 补上索引与归属(把 `moveOne` 的后半段幂等重放一遍);目标目录不在 → payload 没走,直接丢弃条目。 - 修复抛错时条目**留在**日志里而不是被删:下次启动重试,而这条记录是"会话正处于两个家之间"的唯一证据。 ## 8. 相关文件 | 文件 | 职责 | |---|---| | `lib/paths.js` | 目录解析、`.location` 跟随、`dataPaths()` 的路径表 | | `lib/store/sidecar.js` | 原子写、revision、损坏隔离、`codedError` | | `lib/store/documents.js` | 四份文档的初始形状与迁移函数 | | `lib/domain/settings.js` | 设置形状的唯一出处、`mergeSettings`、`normalizeSettings` | | `lib/sources/content.js` | 会话内容索引:内存 + 恒落盘、按签名失效、`flush` / `clear` / `rebuild` | | `lib/component/borrow.js` | 借用:扫描别的插件已装的 Electron(只读)、探测、路径表 | | `lib/host/datadir.js` | `describeDataDir` / `inspectDataDir` / `migrateDataDir` | | `lib/host/trash.js` | 移动字节、维持索引、对账、恢复阶梯 | | `lib/host/move.js` | 跨工作区移动/复制、回滚、`reconcileMoves` | | `lib/host/move-journal.js` | 移动意图日志的读写(`recordMove` / `forgetMove` / `pendingMoves`) |