插件自己的全部状态都落在一个目录里。这个目录不是 ~/.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):
~/.dsh 会让备份、迁移、清理的归属变得模糊。<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/,不当缓存重建)。
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:只统计成功会让
累计数看起来像”总花费”,而它其实只是成功那部分的下限。
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、以及迁移目标目录。
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)。
content-index.v1.json四份侧车之外还有一份可删的文件,装的是会话内容索引的缓存:{ version: 2, savedAt, sessions },
sessions[id] 带 sig(path\u0000sizeBytes\u0000activityAt)与 text。它和侧车的区别是
“删了只损失时间”:下次打开会话内容搜索时重读日志重建一遍,答案一模一样。version 一升旧文件就
被忽略并就地覆盖(文件名里的 .v1 是槽位、不是版本,改名只会让旧文件变成垃圾):v1 装的是
“只有用户那一半”的会话文本,留着它就会一直回答”没搜到”而信号又永不变化。所以
DOCUMENT_NAMES,没有 revision、没有乐观锁,写入走同一个 writeAtomic。search.indexCache 已被 normalizeSettings 当未知键丢掉):派生文件删掉
只花一次重建,而”不写缓存”只会让每次启动都重读一百多 MB 日志。删它的入口是设置页的「删除索引缓存」、
搜索范围弹窗里跳过去的那一组,以及「清除数据」。version 不匹配就当没有(不回写迁移);文件坏了、临时目录不可写,搜索照常工作,只是每次重读。lib/store/sidecar.js 保证两件事:
.tmp/)→ flush → rename 覆盖目标。崩溃只会留下旧文件,
不会留下半个 JSON。revision += 1。客户端带着它看到的 revision 来,
不匹配就 revision-conflict,而不是静默覆盖别人刚做的改动。读损坏(不是 JSON 对象)时文件被改名成 <name>.corrupt-<ts>.json 移进 quarantine/,
然后以空文档重建——不会因为一个坏文件让插件起不来。
放在 quarantine/ 而不是 .tmp/:.tmp/ 每次启动都会清空,那份”留着给人手工恢复”的原件
会被自己删掉。启动时只清 .tmp/ 下的 *.tmp(原子写的残留),*.corrupt-* 会被搬进
quarantine/。
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)是三级阶梯:
第 3 级就是”未分组”能当安全落点的原因:workspace 成员关系存在 DSH 的 registry 里, 一个不再属于任何 workspace 的 id 自动出现在未分组。
清空回收站时,指向已删会话的快捷方式会一并删除——引用不能活得比目标久。
清空回收站 ≠ 清除数据。 前者只处理回收站里的 payload(一次一个会话,可恢复的那部分仍然是
活的),后者是设置页「危险操作」里那一个按钮:四份侧车文档(含虚拟树 tree.v1.json)、trash/ 里剩下的全部 payload、
quarantine/ 与 .tmp/ 一起清空(目录本身留下),删完 invalidateMemory() 抖掉面板的
memo。它不可逆,所以 host 先验用户打的字是不是 DELETE,再过一遍 isUsableDataDir();
浏览器组件不在其中——那是安装,不是用户数据。详见 docs/api.md §4.12。
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 写过的条目回滚,所以目标必须从空开始;
否则要么两套数据混在一起,要么回滚时误删用户的文件。一次移动是两次写:先把 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 }] }
temp + rename,崩溃只会留下旧日志或新日志。reconcileMoves()(lib/host/move.js,lib/index.js 里紧挨 reconcileTrash 调用)读剩下的条目:
目标目录在 → 补上索引与归属(把 moveOne 的后半段幂等重放一遍);目标目录不在 → payload 没走,直接丢弃条目。| 文件 | 职责 |
|---|---|
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) |