dsh-session-navigator

数据目录

插件自己的全部状态都落在一个目录里。这个目录不是 ~/.dsh 的子目录,而是它的兄弟:

C:\Users\<user>\.dsh                     ← DSH 自己的
C:\Users\<user>\.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. 目录内容

<dataDir>/
├── 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/           读不出来的文档原件(`<name>.corrupt-<ts>.json`)
├── trash/
│   ├── index.v1.json     回收站索引
│   └── <trashId>/        被删会话的 payload(整个会话目录)
├── component/            可选的 Electron 运行时(见 component.md)
└── detached/
    ├── window.json       独立窗口的几何与控制端口
    └── chromium/<hash>/  helper 专用 Chromium profile

detached/chromium/<hash> 的 <hash> 是 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:「总结就是让你总结标题 描述 标签」)。 三个字段各自独立——可以只要标题,也可以只要标签;没被要的字段既不出现在提示词的契约里,也不会覆盖已存的值。

{
  "sessions": {                      // 以会话 id 为键,与会话目录无关:会话搬了字段跟着走
    "<sessionId>": {
      "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 的完整形状:

{
  "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": false },      // 空闲时自动建立索引:唯一的搜索索引开关,默认关
  "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 一列只用一个单位,方便竖着比大小。 两者的默认值都等于改造之前的样子,所以升级不会改变任何人的列表。

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 装的是 “只有用户那一半”的会话文本,留着它就会一直回答”没搜到”而信号又永不变化。所以

4. 原子写与乐观锁

lib/store/sidecar.js 保证两件事:

读损坏(不是 JSON 对象)时文件被改名成 <name>.corrupt-<ts>.json 移进 quarantine/, 然后以空文档重建——不会因为一个坏文件让插件起不来。 放在 quarantine/ 而不是 .tmp/:.tmp/ 每次启动都会清空,那份”留着给人手工恢复”的原件 会被自己删掉。启动时只清 .tmp/ 下的 *.tmp(原子写的残留),*.corrupt-* 会被搬进 quarantine/。

5. 回收站

lib/host/trash.js 做的就是文件管理器做的事:

操作 动作
删除 rename <sessions>/<project>/<session>/ → <dataDir>/trash/<trashId>/
恢复 rename 回去
清空 rm -rf payload 目录

跨卷时 rename 退化成 copy-then-delete,其余流程不变。

索引条目(lib/domain/trash.js:95):

{
  "sessionId": "01J…",
  "trashId": "k3f9ab12",
  "title": "…", "cwd": "…", "workspaceId": "…", "workspaceTitle": "…",
  "sizeBytes": 1234567, "createdAt": 1730000000000,
  "originalLogPath": "<sessions>/<project>/<session>/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):

7. 跨工作区移动的意图日志

一次移动是两次写:先把 payload 挪到目标 project 目录,再让 workspace 注册表知道这件事 (lib/host/move.js 文件头)。中间崩溃会留下”字节在目标、注册表还指着旧家”的状态——会话 在两边都看不到,正是回收站用 pending + reconcileTrash 挡掉的那种情况。

move-journal.v1.json(lib/host/move-journal.js)是移动版的同一道保险:

{ "version": 1, "moves": [{ "sessionId": "…", "from": "…", "to": "…", "logName": "…",
                            "workspaceId": "…", "header": { "id": "…", "cwd": "…" },
                            "startedAt": 1760000000000 }] }

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/host/datadir.js describeDataDir / inspectDataDir / migrateDataDir
lib/host/trash.js 移动字节、维持索引、对账、恢复阶梯
lib/host/move.js 跨工作区移动/复制、回滚、reconcileMoves
lib/host/move-journal.js 移动意图日志的读写(recordMove / forgetMove / pendingMoves)