# HTTP API 所有端点都挂在 `/session-navigator/api` 前缀下,由 `lib/index.js:624` 的 `ROUTES` 表定义, `lib/index.js:2195` 的 `handle()` 分发。共 **77 条**。 ## 1. 信封 ```jsonc // 成功 { "ok": true, "result": <任意 JSON> } // 失败 { "ok": false, "error": "给人看的消息", "code": "给机器分支的稳定码" } ``` - 只有 `code` 是契约,`error` 的文案随时可以改。 - 响应头固定 `Cache-Control: no-store`。 - 请求体上限 8 MiB,非 JSON 或超限都是 `bad-request`(`lib/http.js:57-79`)。 - 空 body 解析成 `{}`。 **四条路由不写信封**,它们自己接管响应:`GET /bridge/events`(SSE)、`GET /export`(文件)、 `GET /ui*`(静态资源)。`handle()` 在 `headersSent` 后不再追加信封(`lib/index.js:2218`)。 ## 2. 通用约定 ### 乐观锁:revision 四份侧车文档各自带单调递增的 `revision`。写入时客户端声明"我基于哪个版本": | 写法 | 例子 | |---|---| | HTTP 头 | `If-Match: "12"`(引号可有可无,空标签 `""` 表示不表态) | | body 字段 | `{ "revision": 12, ... }` | 弱标签 `W/"12"` 按 `12` 读——它声明的就是那个版本,只是允许内容被等价改写;`*` 与空标签 一样表示不表态,回落到 body。给了**不是版本号**的值(`If-Match: abc`、`1, 2`)是 `400 bad-request`,而不是悄悄当没给——否则一个写错的头会变成"最后写入者赢"。 两者都不给 = 最后写入者赢(单窗口用户的预期)。给了但落后 = `409 revision-conflict`, 客户端应重新拉取后重试。实现见 `lib/index.js:1932`(读期望值)与 `lib/store/sidecar.js`(比对)。 ### 迁移守卫 数据目录迁移期间(`api.migration !== null`),**除 `/datadir*` 之外的所有非 GET/HEAD 请求** 一律 `409 migration-in-progress`(`lib/index.js:2208`)。否则写入会落进正在被复制的旧目录。 ### id 约定 | 前缀 | 含义 | 例子 | |---|---|---| | `f-` | 固定节点 | `f-root` / `f-all` / `f-favorites` / `f-todos` / `f-trash` / `f-ungrouped` | | `w-` | DSH workspace 的投影节点 | `w-` | | `d-` | 用户文件夹 | `d-k3f9ab12` | | `s-` | 快捷方式 | `s-k3f9ab34` | | 无前缀 | 原生 id(会话 / workspace) | `01J…` | `f-` 与 `w-` / `d-` 由 host 生成;快捷方式的 `targetId` 永远是**目标自己那套 id 空间**里的 id (文件夹 `d-…`、workspace 去掉 `w-`、会话原样),这样解析时不用猜。 ## 3. 错误码 → HTTP 状态 `lib/index.js:2310`(`statusFor`)。DSH 原生的命名空间码原样透出,不翻译。 | code | 状态 | 含义 | |---|---|---| | `bad-request` / `gateway/bad-request` | 400 | 参数缺失或形状不对 | | `session/title-invalid` / `workspace/name-conflict` | 400 | DSH 拒绝的标题 / 重名 | | `subagent-unsupported` | 400 | 子智能体会话不能单独操作:它跟随父会话(见 §3.2) | | `session/not-found` / `workspace/not-found` / `trash-not-found` | 404 | 目标不存在 | | `component-not-installed` | 404 | 浏览器组件未就绪 | | `target-exists` / `session-active` / `session-corrupt` | 409 | 目标已存在 / 会话在跑 / 日志损坏 | | `workspace/session-active` | 409 | workspace 里还有在跑的会话 | | `revision-conflict` | 409 | 乐观锁失败 | | `datadir-same` / `datadir-not-empty` / `migration-in-progress` | 409 | 数据目录迁移的三种拒绝 | | `component-busy` / `component-in-use` | 409 | 组件正在被别的操作占用 / 窗口开着不能删 | | `nothing-pending` | 409 | 一键补齐时所有会话都已经有描述和标签了(这不是错误,是"没活干") | | `ai/no-fields` | 409 | 模型没被要求写任何字段——三个开关全关了,调用没有意义 | | `nothing-to-apply` | 409 | 点了「应用」但记录里那个字段是空的 | | `too-many` | 413 | 一次要求生成的会话超过 `MAX_BATCH = 500` | | `llm/MISSING_CREDENTIAL` / `llm/INVALID_CREDENTIAL` / `llm/AUTH` | 401 | 模型服务拒绝了这个 profile 的凭据 | | `llm/RATE_LIMIT` / `llm/QUOTA` / `llm/ACCOUNT_QUOTA` | 429 | 被限速或额度用完 | | `llm/CONTEXT_WINDOW_EXCEEDED` / `llm/empty` / `llm/tool-only` / `llm/no-finish` | 422 | 会话太长 / 模型两次都没吐字 / 模型只想调用工具(请求里并没有工具) / 流没有以 `stop` 结束 | | `ai/bad-answer` | 422 | 两次尝试都没给出要的 JSON 对象(或缺少要的字段),回答已丢弃、没有写半成品 | | `nothing-to-summarize` | 422 | 这个会话没有可读的对话(只剩注入上下文或空日志) | | `llm-unavailable` | 501 | 该 profile 没有 `llm` 服务或没有可用模型 | | `confirm-required` | 400 | 清除数据没拿到 `DELETE`,一个字节都没动(§4.12) | | `reset-refused` | 409 | 数据目录不可用(根目录 / `~/.dsh` 或它的祖先),既然是清空就更不能动(§4.12) | | `component-disk-full` | 507 | 磁盘不足 | | `disk-full` | 507 | 同上的通用码(写文件时 `ENOSPC`) | | `io-error` | 500 | 数据目录读不了(`ENOENT`/`EACCES`/`EIO`… 被翻译成这个,不再冒充 `bad-request`) | | `move-incomplete` / `move-rollback-failed` | 500 | 跨卷拷贝不完整、回滚没做完——会话**没有**被删,见 `move.js` | | `update-unreachable` | 502 | 连不上 GitHub,或答复不是 release JSON(更新检查,见 §4.11) | | `open-failed` | 502 | 系统浏览器拉不起来(Star / Issue 链接) | | `capability-missing` | 501 | 该 profile 缺这个服务 | | `cross-site` | 403 | 跨站写请求(`Sec-Fetch-Site: cross-site`) | | `unsupported-media-type` | 415 | 写请求没用 `application/json` | | 其它(含 `ui-asset-missing`) | 500 | 未预期的服务端故障 | ### 3.1 写请求的同源约束 路由挂在 DSH 自己的端口上,没有 token,所以浏览器里任何一个页面都能往这里 POST—— 响应它读不到,但 `POST /trash/empty` 已经生效了。`lib/index.js` 的 `assertSameOrigin()` 用两个浏览器自己加、脚本伪造不了的信号堵住这条路(只对 `GET`/`HEAD` 以外的请求生效): - `Sec-Fetch-Site: cross-site` → `403 cross-site`; - `Content-Type` 不是 `application/json` → `415 unsupported-media-type`。JSON 不是 CORS 的"简单内容类型",跨站带这个头发请求会先触发预检,而 DSH 不应答预检。 插件自己的前端一直带 `content-type: application/json; charset=utf-8`,所以不受影响。 用 curl 手工调写接口要显式加这个头: ```bash curl -X POST -H 'content-type: application/json' -d '{"ids":["s1"]}' \ http://127.0.0.1:63334/session-navigator/api/trash ``` 没有 `Sec-Fetch-Site` 的请求(curl、脚本、本机工具)照常放行——它们不是跨站浏览器请求。 ### 3.2 子智能体的绑定 子智能体是父会话一次委派的产物,DSH 仍然把它写成完整的会话目录,但它不是用户可以 单独处置的会话。规则集中在 `lib/domain/subagents.js`,路由侧只有一个入口 `assertStandalone(api, ids)`(`lib/index.js:1828`): | 操作 | 子智能体 | 为什么 | |---|---|---| | 读 / 打开 / 导出 / 显示位置 | 允许 | 只读 | | 删除、还原、清空 | 拒绝;由父会话级联 | 父会话删除时子会话一起走(`withDescendants`),单独删会留下孤儿委派 | | 剪切 / 复制 / 移动 | 拒绝 | 搬走子会话等于把它提升成独立会话,父会话的委派历史就断了 | | 收藏 / 待办 / 重命名 | 拒绝 | 这些字段描述"用户的会话",而子会话属于父会话的那次运行 | | 归档 | 拒绝;由父会话派生 | `sessions.js` 的 `archivedUp` 让子会话跟着祖先的归档态,写一条 per-child 记录会让"这次运行归档了"变成 N 条事实 | | 派生(fork) | 拒绝 | 分叉是给会话开副本,子会话没有可开的东西 | `isSubagent` 由 header 决定(`origin === "subagent"`,或 `origin === null && delegationDepth > 0`), **不是** `parentSession` 非空:fork 也带 `parentSession`,但它是用户要的独立会话,所有操作都正常。 客户端在 `session-state` 的响应里拿到同一个 `subagent` 位,据此隐藏按钮、只读行菜单,并给行 加 `bound-row`/`chk-bound` 标记——隐藏是礼貌,上面这张表才是规则。 ## 4. 路由 ### 4.1 快照与设置 | 路由 | body / query | 返回 | |---|---|---| | `GET /bootstrap` | `?refresh=1` 跳过 TTL | 见下 | | `GET /settings` | — | `{ settings, revision, capabilities, dataDir, version }` | | `PUT /settings` | 局部设置文档 | `{ settings, revision }` | | `GET /theme` | — | `null` 或 `{ mode, tokens, at }` | | `POST /theme` | `{ mode?, tokens? }` | 同 `GET /theme` | `GET /bootstrap`(`lib/index.js:631`)是面板唯一的一次往返,字段: ```jsonc { "hello": { "plugin": "dsh-session-navigator", "schema": 1 }, "capabilities":{ /* 见 lib/capabilities.js */ }, "workspaces": { "workspaces": [ { id, title, path, sessionIds, createdAt, updatedAt } ] }, "sessions": [ /* 合并视图 + home + homeOverride */ ], "trashed": [ /* 回收站条目,按删除时间倒序 */ ], "tree": { /* 节点表 + sessionHome + sessionPlacement + pins + quickAccess + revision */ }, "tags": { /* 使用索引:tag → 会话 id 列表 */ }, "tagLibrary": [ /* 共享标签库(创建序)+ 仅被使用的标签 */ ], "favorites": [ /* 会话 id */ ], "todos": [ /* 会话 id */ ], "settings": { /* 归一化后的完整设置文档 */ }, "revisions": { "tree": 7, "annotations": 3, "settings": 1 }, "stats": { /* 扫描统计 */ }, "scannedAt": 1730000000000 } ``` `sessions[]` 的 `home` 是它显示在哪个容器节点里,`homeOverride` 表示这是侧车的虚拟放置而非 DSH 自己的归属(`lib/index.js:1808`)。两者都由 host 算好,客户端不重放规则。 一个字段不是 DSH 给的,是插件自己从日志里读出来的(`lib/sources/logfacts.js`): | 字段 | 含义 | 从哪来 | |---|---|---| | `blank` | 从未开始过一轮对话的会话 | 日志里没有 `turn/start`。DSH 的 projcache 也记这个,但它只覆盖 DSH 自己列过的会话;插件走目录扫描,所以以日志为准,读不到时回落到 projcache,两者都没有就是 `null`(未知,不隐藏) | 读取成本:`blank` 只在日志较小时读全文(≤256 KB),较大的日志只读头部 64 KB 的几帧, 读不到就报未知而不是报空。结果按 `path|size|mtime` 缓存,所以只有正在写的那个会话会被重读。 日志只回答这一个问题。会话是否正在跑、某一轮是否被切断,插件都不推断——DSH 自己在内存里 只有一个 `running`(`packages/api/session-controller/src/list.ts:120`),面板报同一个答案 (列表的状态阶梯见 §5.1),没有 `interrupted` 这种自造字段。 更新检查、缓存清理和三个项目链接在设置页的「关于」组里,单独记在 §4.11。 `GET /settings` 单独存在,是因为设置页挂在面板之外,不该为渲染一个表单付一次全量扫描。 `PUT /settings` 收**局部**文档(`{ shell: { modal: { dim: 0.2 } } }` 合法),未知键被 `mergeSettings` 丢掉而不是落盘。 ### 4.2 标签 | 路由 | body | 返回 | |---|---|---| | `GET /tags` | — | `{ tags, library }` | | `POST /tags` | `{ tag }` | `{ tag, library, revision }` | | `DELETE /tags/:tag` | — | `{ tag, library, revision }` | 删除库里的标签**不会**把它从会话上摘掉——那些会话仍然带着它,只是不再出现在候选列表的库段。 ### 4.3 会话注解 | 路由 | body | 返回 | |---|---|---| | `PATCH /annotations/:id` | `{ tags?, note?, favorite?, todo? }` | `{ id, annotation, revision, tags, favorites, todos }` | | `GET /session-state/:id` | — | `{ id, favorite, todo, archived, trashed }` | `PATCH` 的 `annotation` 在该条目已被清空时是 `null`——注解不存"空壳",一旦没有任何信息就整条删除。 `:id` 必须是合法会话 id(`isAnnotationId`:非空、≤ 上限、不在 `__proto__` / `constructor` / `prototype` 里、只含 `[A-Za-z0-9._:-]`),否则 `400 session id is not a valid id`。注解文档用会话 id 当键,`__proto__` 会写原型而不是条目——补丁静默消失、接口却回报成功,所以这一层在路由和 `patchAnnotation` 里各挡一次。 `GET /session-state/:id` 是给 DSH 自己的会话标题栏和行菜单用的:它们没有 bootstrap 快照, 只想知道三个布尔。`trashed` 有两个来源(本插件的回收站索引,或 DSH 报告的 `trashed`), 任一为真即"不在"(`lib/domain/session-state.js:32`)。 ### 4.4 树:文件夹、快捷方式、置顶、快速访问、放置 | 路由 | body / query | 返回 | |---|---|---| | `POST /folders` | `{ name, parent? }` | `{ id, folder, revision }` | | `PATCH /folders/:id` | `{ name }` | `{ id, folder, revision }` | | `DELETE /folders/:id` | `?lift=0` 拒绝而非上提 | `{ ...结果, revision }` | | `POST /shortcuts` | `{ kind, targetId, parent?, label? }` | `{ id, shortcut, revision }` | | `DELETE /shortcuts/:id` | — | `{ removed, revision }` | | `POST /nodes/:id/pin` | `{ pinned?: boolean }` | `{ nodeId, pinned, revision }` | | `POST /nodes/:id/quick-access` | `{ quickAccess?: boolean }` | `{ nodeId, quickAccess, revision }` | | `POST /quick-access` | `{ ids }`(整条顺序,首行在前) | `{ order, quickAccess, revision }` | | `POST /nodes/:id/move` | `{ parent }` | `{ nodeId, parent, revision }` | | `POST /sessions/placement` | `{ ids, parent? }` | `{ ids, parent, revision, sessionPlacement }` | - `DELETE /folders/:id` 默认把内容**上提到父节点**(`lift` 默认 1);`?lift=0` 时若文件夹非空 则拒绝。同时会连带删掉指向该文件夹的快捷方式——指向已删节点的行比没有这行更糟。 - `POST /shortcuts` 的 `kind ∈ {folder, workspace, session}`,永远是**引用**,绝不移动目标。 - `POST /nodes/:id/move` 先过 `canMove()`(`lib/domain/tree.js`),不合法直接带规则码抛错。 - `POST /sessions/placement` 只写侧车的 `sessionPlacement`,**不动文件**:DSH 从 header 的 `cwd` 推导会话归属,改它意味着搬 payload。`parent` 省略 = 清除覆盖。 - `POST /nodes/:id/quick-access` 只写侧车的 `quickAccess[id]`(`quickAccess: false` 删掉它),只有真实容器能当书签 (`isBookmarkable`:文件夹还在的 `d-…`,或 `w-…`)。视图、收藏 / 待办、快捷方式都不是「地方」——它们在左栏 已经是一行。它和 `POST /nodes/:id/pin` 是两件事:pin 是**在本来的带子里排前面**,quick-access 是**同一容器多一个 入口**(共用一张表时,置顶一个文件夹会把它从 目录 里挪走)。 - `POST /quick-access` 一次写整条带子。加一个(把容器拖到带上)和重排(把一个书签拖到另一个上)是同一句话 「现在就是这个顺序」,所以没有「移到第 N 位」这种单行接口。不合法或已消失的 id 被**跳过**而不是 400(手势来自 一棵活的树,一条过期行不该作废整个动作),返回的 `order` 只含真正写进去的行;已经在带里但没出现在 `ids` 里的 书签保留原序号,排到新写的下面。 ### 4.5 会话操作 | 路由 | body / query | 返回 | |---|---|---| | `POST /sessions/:id/rename` | `{ title }` | `{ sessionId, title, seq }` | | `POST /sessions/:id/archive` | `{ stopActivity?: boolean }` | `{ archivedSessionIds }` | | `POST /sessions/:id/unarchive` | — | `{ archivedSessionIds }` | | `POST /sessions/:id/pin` | — | `{ pinnedSessionIds }` | | `POST /sessions/:id/unpin` | — | `{ pinnedSessionIds }` | | `POST /sessions/:id/fork` | `{ atSeq?: number }` | `{ sessionId, parentSessionId }` | | `POST /sessions/:id/log-path` | — | `{ path, name }` | | `POST /sessions/:id/reveal-file` | — | `{ path, opened }` | | `POST /move` | `{ ids, target: { workspaceId }, copy? }` | `{ results }` | | `POST /trash` | `{ ids }` | `{ trashed, skipped, errors }` | | `POST /restore` | `{ ids }` | `{ restored, skipped, errors }` | | `POST /purge` | `{ ids }` | `{ purged, skipped }` | | `POST /trash/empty` | — | `{ purged }` | | `GET /export` | `?sessionId&format=md\|jsonl&download=1` | 文件本体 | 前六个都是**DSH 自己的概念**,走 DSH 的服务(`lib/host/actions.js`),插件不落盘: - 改名追加一条用户署名的 `session/title` 事件并压制自动标题生成;随后显式 flush 投影缓存, 否则新标题最多要等 5 秒(缓存默认 200 事件 / 5 秒节流)。 - 归档不搬文件,只在列表里降级;有会话在跑时需 `stopActivity: true`。 - `fork` 省略 `atSeq` 时与官方默认一致:从最后一个完成的回合继续。 - 派生失败的子代理会话返回 `subagent-unsupported`。 - 所有"单独改存储"的路由(`/trash`、`/restore`、`/purge`、`/move`、`/copy`、`/annotate`、 `/archive`、`/title`、`/fork`)先过 `assertStandalone`:ids 里只要有一个子智能体就整单拒绝 `subagent-unsupported`,不做"跳过后继续"。规则在 `lib/domain/subagents.js`,理由在 §3.2;`/trash`、`/restore`、`/purge` 接受父会话后由 `withDescendants` 把整条委派链一起带走。 `POST /move`(`lib/host/move.js`)是唯一会**改会话文件**的操作:重写 header 的 `cwd` + 搬目录 + 换 registry 登记,三件事一个事务;只有其中一件成功会导致下次启动找不到会话。 `copy: true` 写第二份独立会话(新 id)而不是移动。有会话在跑时拒绝(`session-active`)—— DSH 还握着旧路径的写句柄,从底下挪走会损坏尾部。 `POST /sessions/:id/log-path` 是"复制文件路径":只解析不打开,把会话日志的绝对路径交给面板写剪贴板。 回收站里的会话也答得出来(走索引),所以它和 `reveal-file` 共用同一套解析、却不碰操作系统。 `POST /sessions/:id/reveal-file` 是"跳转至文件位置":在资源管理器/Finder 里**选中**该会话的日志文件本身, 不是它所在的目录。客户端只送 `sessionId`,路径由 host 解析(`sessionLogFile`):活着的会话取快照里的 `logPath`,已进回收站的会话取 `/trash//<原文件名>`(回收站索引记着 `originalLogPath`)。两处都查不到就 `session-not-found`,不做猜测。它和 `POST /reveal-path` 分开, 是因为后者的白名单只认目录。 Windows 上还多两件事,都是踩过的坑(`lib/host/actions.js`): - **分隔符**:`C:/x/y` 交给资源管理器的 `Explore` 找不到窗口,交给 `explorer.exe` 会打开"文档"。 所以只在实际调系统时把 `/` 换成 `\`(`nativePath`),返回值仍是调用方给的原样路径。 - **前台**:窗口"打开了"和"用户看见了"是两件事。脚本会 `ShowWindow`+`SwitchToThisWindow`+ `SetForegroundWindow`,再用 `GetForegroundWindow()` 比对句柄,最后输出一个状态词: `opened`(新建并到前台)/ `focused`(复用已有窗口并到前台)/ `behind`(存在但没抢到前台)/ `activated`(只能按标题激活,未验证)/ `none`(根本没窗口)。面板按这个词决定提示文案, `behind` 会明说"窗口没有到前台",不再拿"已打开"糊弄。 - **为什么不用 `explorer.exe /select,`**:这条路在本机不通——正斜杠被 shell 直接拒绝(`E_INVALIDARG`), 而命令行交接走 DDE,DSH 进程是提权的(`WindowsPrincipal.IsInRole(Administrator)` 为真)驱动不了 非提权的资源管理器,于是它退回去打开"文档"。原生 API 把这两个坑一起绕开。 **三级顺序**(`lib/host/actions.js` 的 `revealOnWindows`,前一级成功就不再往下走): 1. **shell API**(首选,`lib/host/native-reveal.js`):`SHOpenFolderAndSelectItems`(shell32)。这就是 资源管理器"在文件夹中显示"和 Electron `shell.showItemInFolder` 用的那个调用——由 shell 自己开窗 并置前,实测 42–57 ms(进程内第一次 155 ms),不产生任何外部进程。Node 没有内置 FFI,所以经 `koffi`(Node-API 预编译绑定,不需要编译器)调用;绑定缺失、平台不是 Windows、或调用失败时 该模块返回 `null`/`{ok:false}`,自动落到下一级——**缺绑定只损失速度,不损失功能**。 2. **常驻 PowerShell**(`lib/host/reveal-agent.js`):`Shell.Application.Explore` + 前台验证,冷启动 约 1.8 s,之后每次 130–180 ms。只有在第 1 级不可用时才会在面板打开时预热。 3. **DSH `openWorkspacePath`**:最后兜底。反过来做过(先 DSH),结果是**没有桌面宿主的构建里它会正常 resolve 而什么也不做**——面板提示"已打开",桌面上却没有窗口。DSH 的返回值不能证明窗口出现过。 `POST /sessions/:id/reveal-dir` 是"在文件管理器中显示":打开会话的工作目录 `cwd`;会话没记过 `cwd` 时退到日志所在目录。同样只收 `sessionId`,不让调用方自己给路径。 `move` 是**逐条**的:一条搬不动(`session-active`、目标目录被占、payload 中途消失)不会取消 其余,`results` 里每条是 `{ sessionId, ok, code?, message?, ... }`。两种例外是整单错误: 目标 workspace 解析不了时,一条都不动(`bad-request` / `workspace-not-found`); **全部**失败时把第一条错误抛出来,避免回一个全是 `ok: false` 的 200——客户端只看信封的 `code`,看不了逐条结果。 `POST /trash` 是真移动:会话目录离开 DSH 的 sessions 树,所以 DSH 自己的侧栏里也会消失。 同样是逐条的:`trashed` 是搬走的,`skipped` 是本来就在回收站或没有日志的,`errors` 是搬失败 的(`session-active` 等),三者合起来正好是请求的 id 集合。并发删除同一会话时,先在文档锁里 认领条目(`pending: true`)再搬文件,后到者看见条目已存在就记 `skipped`,不会各写一个 trashId 再把对方的条目录掉。 恢复按 `original → workspace → ungrouped` 三级阶梯落地(见 [data-directory.md](data-directory.md))。 `POST /restore` 是**逐条**的:某一条恢复不了(目标路径又被占了、payload 中途消失)不会取消整批, 失败的那条进 `errors: [{ sessionId, code, message }]` 并同时计入 `skipped`,其余照常恢复。 `GET /export` 对回收站里的会话返回 `session/not-found`——payload 已经不在 DSH 的树里了, 回收站只留一份描述。导出**容忍正在写入的尾部**(`readArtifact(…, { tolerateTail: true })`): 最后一个不完整的帧或半行被丢掉,前面的完整事件照常导出。读来重写的路径(move/trash)不能 容忍——它必须保留每一个字节的去向。 文件名按**码点**截断到 80(`Array.from(...).slice(0, 80)`),不是 `slice(0, 80)`:后者按 UTF-16 切,标题末尾是 emoji 时会切出孤立代理项,`encodeURIComponent` 随即抛 `URIError`,一次导出变成 500。 导出的是**说过的全部内容**:用户的消息、助手的回复、工具调用与它们的输出。三类东西被当成 "没人说过的话"去掉,并在文件头部逐类写出条数,方便读者对账:DSH 每轮注入的上下文(`user/message` 里 `data.source.kind` 不是 `user` 的那些:时间戳、运行上下文、技能目录、审批提示……)、系统提示、 `developer/message` 的工具清单变更。用户消息没有 `source` 字段时按老日志处理,算用户说的。 **surfaceOp 与压缩只报告,不过滤。** 真实日志里 `surfaceOp` 是字符串 `"append"` 或 `{op: "replace", startSeq, endSeq}`(含端点),DSH 压缩一次长会话会把整段——实测一次 4094 个、 一次 4638 个事件,其中 480/499 次工具调用——换成一个 `source.kind = "compact-checkpoint"` 的摘要 消息。若按掩码把这段丢掉,一次 26 条用户消息、1142 次工具调用的会话只导出 5 条消息与 163 次调用, 正是"导出不全"的来源。所以导出**保留**被替换的片段、在片段起点写一行 ⚠ 注记、把摘要按 `## 上下文压缩摘要`(标注"由 DSH 自动生成,不是用户原话")单独成段,并在头部报出被替换与被剪 (`compaction/prune` 的 `shadowedSeqs`)的事件数。`maskedSeqs` 因此只用来打注记,不再当过滤器; 它的范围有上界(`MAX_MASKED_RANGE = 100_000`),日志里写 `to: 1000000000` 不会循环十亿次。 助手消息的内容在 `data.message.content` 里(不是 `data.content`),工具调用既有 block 又有独立的 `tool/call` 事件,按 `callId` 去重;时间戳读顶层 `time`(不是 `at`)。读错任何一条都会让整类 内容静默消失。 Markdown 默认带工具调用与输出,单条输出超过 `toolResultLimit`(4000 字符)时截断并注明"完整输出见 JSONL 导出";JSONL 一条回合一行,参数与输出全量。思考过程(`reasoning`)两种格式都默认不写。 文件名按**码点**截断到 80(`Array.from(...).slice(0, 80)`),不是 `slice(0, 80)`:后者按 UTF-16 切,标题末尾是 emoji 时会切出孤立代理项,`encodeURIComponent` 随即抛 `URIError`,一次导出变成 500。 渲染时不认识的 block 类型不会让整条消息消失:它被写成一个可读的占位块,所以一条只含图片或 只含工具结果的助手消息仍然出现在 md 与 jsonl 里。 ### 4.6 workspace | 路由 | body | 返回 | |---|---|---| | `POST /workspaces/:id/rename` | `{ title }` | `{ workspaceId, title }` | | `POST /workspaces/:id/delete` | — | `{ workspaceId, deleted }` | | `POST /workspaces/:id/path` | — | `{ path, name }` | | `POST /workspaces/:id/reveal` | — | `{ path, opened }` | | `POST /reveal-path` | `{ path }` | `{ path, opened }` | 删除 workspace 只删登记:目录和所有会话日志都在,会话回落到未分组(与 DSH 原生删除语义一致)。 `reveal` 在资源管理器里定位目录;workspace 没有记录路径时 `bad-request`。 `POST /workspaces/:id/path` 是"复制目录地址":只解析路径、不碰操作系统,和 `POST /sessions/:id/log-path` 成对——一个给文件,一个给目录。面板因此可以把地址粘到编辑器、 终端或另一台机器的对话框里,而不必在宿主所在的那台机器上开窗。 `POST /reveal-path` 只接受**本插件已经列出的目录**:某个会话的 `cwd`,或某个 workspace 自己的 `path`(`lib/index.js` 的 `knownDirectory`)。其他路径一律 `400 path is not a directory this plugin knows about`——客户端只会送它正在显示的会话的 cwd,所以这条路由不该是一个"打开任意路径"的通用原语 (`\\host\share` 交给资源管理器是凭据泄露面,不是便利)。子目录不算:比对的是完整路径,不是前缀。 ### 4.7 数据目录 | 路由 | body | 返回 | |---|---|---| | `GET /datadir` | — | `{ current, recorded, fromEnv, exists, sizeBytes, contents }` | | `POST /datadir/inspect` | `{ target }` | `{ target, current, exists, writable, sameVolume, mode, sizeBytes, currentSizeBytes }` | | `POST /datadir/migrate` | `{ target, mode? }` | `{ target, mode, steps, revision }` | `inspect` 是预检,不碰任何东西:目标必须是绝对路径、不是文件系统根、不是当前目录、可写、 且不含另一套安装。`mode` 由是否同卷决定(同卷 `move`,跨卷 `copy`),`move` 是唯一会删旧目录的模式。 详见 [data-directory.md](data-directory.md)。 ### 4.8 浏览器组件 | 路由 | body | 返回 | |---|---|---| | `GET /component/status` | — | 见下 | | `GET /component/scan` | — | `{ home, candidates, current }` | | `POST /component/install` | `{ mirror?, borrowed? }` | 同 status | | `POST /component/cancel` | — | 同 status | | `POST /component/remove` | — | 同 status | | `POST /component/retry` | — | 同 status(续传已有 `.part`) | status 的形状(`lib/component/manager.js:404-429`,整个 state 加上算出来的几个字段): ```jsonc { "phase": "absent | downloading | verifying | extracting | ready | error", "source": "reuse | borrowed | null", "borrowed": "借来的运行时绝对路径(source=borrowed 时,否则 null)", "receivedBytes": 0, "sizeBytes": 0, "approxDownloadBytes": 157455369, "approxDiskBytes": 383650096, "canRemove": false, "pendingCleanup": 0, "error": "上一次失败的原因(phase=error 时)" } ``` `GET /component/scan` 列的是**别的插件已经装好的** Electron(只 `readdir`,不写、不 `stat` `.asar`), `candidates` 每项是 `{ root, binary, electronVersion, hasDefaultApp, hasApp }`,按版本降序;`current` 就是 status, 扫描不受下载影响(不走同一把锁)。`POST /component/install { borrowed }` 借用其中一份:写自己的 `helper-app` 和一条 `borrowed` 记录,**不往被借的树里写任何东西**;路径在数据目录内、或不是能启动的 Electron 时回 `400 component-borrow-invalid`(用户选错了,不是服务器坏了)。 `sizeBytes` 对借来的运行时恒为 0——体积是别人的,也不值得为它遍历几百 MB。细节见 [component.md §8](component.md)。 轮询而不是 SSE:唯一会变的数字是下载百分比,而看它的那个页面只开几秒。 `remove` 会先关掉独立窗口(`component-in-use` 的替代路径),并在删不掉时把路径记进 `pending-cleanup.v1.json`,下次启动 sweep。 ### 4.9 桥与独立窗口 | 路由 | body / query | 返回 | |---|---|---| | `POST /bridge/commands` | `{ kind, payload? }` | `{ seq, kind }` | | `GET /bridge/events` | `?since=` | SSE 流 | | `POST /detached/open` | — | `{ open: true, ... }` | | `POST /detached/close` | — | `{ closed: boolean }` | `kind ∈ {open-session, new-session, reveal, refresh, open-settings}`(`lib/host/bridge.js:148`), payload 只接受 `sessionId` / `workspaceId` / `scope` 三个字符串字段,各自截断到 200 字符。 其它 kind 一律 `bad-request`——桥是一根窄管子,不是插件的遥控器。 `GET /bridge/events` 先发 `event: ready`(带当前 `cursor` 和允许的 kinds),再补发 `since` 之后的命令,之后是实时的 `event: command`;每 15 秒一条 `: keep-alive` 注释帧。 命令帧带 `id: `,重连时服务端优先读 `Last-Event-ID` 头而不是 URL 里的 `since`;订阅者 上限 32,满了回 `503`;`res.write()` 返回 `false` 时直接结束流,让客户端带 `Last-Event-ID` 重连,而不是让缓冲涨下去。细节见 [bridge-and-theme.md](bridge-and-theme.md)。 `POST /detached/open` 在组件未 `ready` 时返回 404 `component-not-installed`。 ### 4.10 静态资源 | 路由 | 文件 | Content-Type | |---|---|---| | `GET /ui` | `assets/detached/index.html` | `text/html` | | `GET /ui/window.css` | `assets/detached/window.css` | `text/css` | | `GET /ui/shim.js` | `assets/detached/shim.js` | `text/javascript` | | `GET /ui/client.js` | `lib/client.js` | `text/javascript` | | `GET /ui/react.js` | `assets/vendor/react.production.min.js` | `text/javascript` | | `GET /ui/react-dom.js` | `assets/vendor/react-dom.production.min.js` | `text/javascript` | 路径是**常量**,不由请求决定读哪个文件,因此没有穿越面(`lib/index.js:97`)。 全部 `no-store`:面板和窗口必须跑同一份代码,缓存一份旧的正是这套安排要消除的漂移。 文件缺失时 `ui-asset-missing`(500)。 ### 4.11 关于:更新检查、缓存清理、链接 设置页「关于」组的三条后端(它后面还有一组「危险操作」,另记在 §4.12)。 | 路由 | body / query | 返回 | |---|---|---| | `GET /update` | `?force=1` 跳过 5 分钟缓存 | `{ current, latest, hasUpdate, url, name, publishedAt, notes, checkedAt }` | | `POST /about/open` | `{ target }` | `{ target, url, opened: true }` | | `POST /cache/clear` | — | `{ freedBytes, caches, tmp, downloads, staging, skipped, tookMs }` | | `GET /release-notes` | — | `{ title, blocks, bytes, updatedAt, version, seen }` | | `POST /release-notes/seen` | `{ version }` | `{ version }`(写进去的记录;空版本是 no-op) | `GET /update` 在 host 侧访问 `api.github.com`(`lib/host/update.js`):客户端是个网页, 直连会撞 CORS;独立窗口里 `window.open` 又被主进程拒掉,两条路都不通,所以这件事只能 host 做。仓库地址只在 `lib/constants.js` 的 `REPO_URL` 写一次,API 地址由它拼出来。 结果缓存 5 分钟:设置页每开一次就问一次 GitHub 的话,未登录的 60 次/小时会很快用完。 `latest` 为 `null` 不是错误——仓库还没有 release,和"连不上"是两回事(后者是 `502 update-unreachable`)。版本比较只按数字段走:`v1.10.0 > v1.9.0`,`1.0` 与 `1.0.0` 相等,预发布版低于同号正式版。`current` 读的是 `package.json`,不是第二份写死的数字。 `POST /about/open` 收的是**名字**不是 URL,白名单 `{ repo, star, issues, releases }` (`lib/host/open-link.js` 的 `LINK_TARGETS`)。这条 API 没有 token,任何页面都能调; 如果它接受任意 URL,就等于给每个网页一个"用系统浏览器打开这个地址"的按钮。所以只认 这四个键,别的(包括 `"https://…"`、`"STAR"`)一律 `bad-request`,且什么都不拉。 打开本身走一条降级链(同一个文件):Windows 先 `rundll32 url.dll,FileProtocolHandler`,这是 一次裸 `ShellExecute`,没有控制台宿主、不继承调用方的窗口状态;失败再退回 `powershell -Command Start-Process`;macOS `open`,Linux `xdg-open` → `gio open`。 判据是子进程的**退出码**,不是"起来了吗":只报 spawn 成功的话,一个起得来却打不开任何东西的 壳子也会被当成成功,用户看到的就是"点了没反应"。全链失败才是 `502 open-failed`,且消息里 带上那个 URL,面板把它原样显示出来,至少还能复制。 `POST /cache/clear` 只清插件自己的缓存(`lib/host/maintenance.js`): - 内存:`reader.invalidate()`、`clearLogFacts()`、各侧车的 `invalidate()`。 - 磁盘:数据目录的 `.tmp`(`cleanTmp`,被中断的写会被**搬进** `quarantine/` 而不是删掉)、 组件的 `download/`(含续传用的 `.part`)与 `.component-staging/`。 组件正在 `downloading / verifying / extracting`,或状态文件读不出来时,后两项跳过, `skipped` 里报 `component-busy`——那正是它在用的目录,删一半等于让它下次从断点装坏。 `quarantine/`(用户自己的坏数据)、DSH 的 `storages/session_projcache` 和会话本身 **从不**在这里被碰。浏览器缓存也不在其中:面板的 `/ui/*` 一律 `no-store`(§4.10), 其余页面缓存属于 DSH 自己的渲染进程和独立窗口,不在插件手里。 `GET /release-notes` 是「更新说明」弹窗的正文,而正文是**文档**,不是代码里的字符串 (`lib/domain/notes.js` + `lib/host/changelog.js`)。改一次文案就是改一个 markdown 文件, 不用动这个插件的源码,更不用等它被重新构建。 两个候选文件,先到先用:仓库里是包目录上一层的 `更新说明.md`,装好的包里只有随包发出去的 `CHANGELOG.md`(包不可能带上自己的父目录)。checkout 优先,所以维护者改完下一次打开就能看到; 打包用的那份就是发布版用户读到的内容,`scripts/sync-notes.mjs` 在 `prepack` 时把它覆盖过去, 所以打出来的包里永远是维护者刚写完的那一份,不需要谁记得手动复制。 解析只认一个很小的子集:标题、无序/有序列表、分隔线、`**粗体**`、`\`代码\``;认不出的行 原样当段落显示,不会从弹窗里消失。文件超过 200 KB 直接 `too-large`——那不是一份更新说明; 两个文件都没有是 `not-found`,弹窗照样打开并把原因写在正文位置,而不是显示一张空卡片。 按 `大小 + mtime` 记住上一次的解析结果:文件几 KB,重新解析一次也不贵,但"改了文案弹窗还是旧的" 正是这套安排要避免的事,所以键里必须带 mtime。 读这份文档的有两个入口,读的是同一条路由: - **设置 › 关于 › 更新说明**:随时可看,每次打开都重新读(上面那条缓存只省解析,不省文件读取)。 - **启动时自己弹一次**:挂在侧栏入口上(`NotesGate`,与面板一起卸载不了),DSH 起来约 3 秒后问一次 `/release-notes`,把响应里的 `version` 与同一个响应里的 `seen` 比对(`notesDecision`):版本不同、或这台机器从没记过版本, 就打开;相同就什么也不做。**第一次装也弹**——第一次启动正是这个版本唯一一次"新"的时候, 更新说明说的就是它;弹在启动而不是等面板打开,则是"用户可能根本不打开面板,但插件已经更新了"。 `version` 随这份响应一起回来,所以判断要不要弹不需要第二次请求,也不需要 `/bootstrap` 去扫会话。 "已读"记在**宿主**的 `/notes-seen.v1.json`(`lib/host/notes-seen.js`),不是 localStorage: DSH web GUI 每次启动换一个端口,而浏览器存储按 origin(含端口)隔离,换端口就是空存储—— 记在那里等于"每次启动都弹"。写回走 `POST /release-notes/seen`(最新的一次覆盖旧的,不需要 revision); 也没写进设置文档,因为"哪个版本看过"不需要一次合并和一条冲突规则。 关闭时才算送达(`close` 里写版本);读失败(`not-found` / `too-large`)在启动路径上**静默**且**不写**记录—— 启动时弹一张"你的文件坏了"的卡片比沉默更糟——下次启动再试一次。一次页面加载只尝试一次 (`notesLayer.asked`),免得侧栏重挂载就再弹一次。 ### 4.12 清除数据 | 路由 | body | 返回 | |---|---|---| | `POST /data/reset` | `{ confirm: "DELETE" }` | `{ freedBytes, documents, trash, quarantine, tmp, keptLocation, dataDir, tookMs }` | 插件里唯一一个**不可逆**的操作(`lib/host/reset.js`),因此有两道门,且都在 host 侧: 1. `confirm` 去掉首尾空白后必须**恰好**是 `DELETE`(`RESET_CONFIRMATION`),否则 `400 confirm-required`——在读到任何文件之前就返回,一个字节都不动。门装在这里而不是 只装在页面上,是因为这条路由没有 token:只靠一颗红按钮拦的话,一次 `fetch` 就绕过去了。 2. `api.dataDir` 必须过 `isUsableDataDir()`(不是文件系统根,不是 `~/.dsh` 也不是它的祖先), 否则 `409 reset-refused`。越是要清空一个目录,越要先确认那是它自己的目录。 过了门,它删的是插件自己的记录: - 四份侧车文档 `tree.v1.json` / `annotations.v1.json` / `settings.v1.json` / `trash/index.v1.json`:**虚拟树**(文件夹、工作区摆放与顺序)、标签、备注、设置、回收站索引; - `trash/` 里的**净内容**。回收站是 move 不是 copy(`lib/host/trash.js`),里面的目录 曾经就是 DSH 的会话目录,删掉它们等于把这些会话永久删掉——这就是它必须被 `DELETE` 拦住的原因; - `quarantine/`(解析不了的文档,是用户自己的字节)与 `.tmp/`(写了一半的原子写残留)。 - `content-index.v1.json`:会话内容索引的缓存(§4.14)。派生文件,删了只是下次重建一次; 返回里的 `contentIndex` 就是它那一项,`{ removed, bytes }` 与 `documents` 同形状。 它**不动**的东西: - 数据目录本身和它的三个子目录——清空而不删除,否则插件下次没地方写; - DSH 自己的会话库:插件只搬过它们,没造过它们; - 浏览器组件(`component/`、`.component-staging/`):那是一次安装,不是用户数据。 要清它按 `POST /cache/clear`(§4.11)。 迁移过的安装(`settings.dataDir.current` 有记录)会在删之前先记下这个路径,删完立刻用一份 新文档写回去(新文档 revision 从 1 开始),否则下次启动会退回默认目录,面板就指向一个空位置。 `keptLocation` 是写回去的那个值,没记录时为 `null`。写不回去也不报错:退回默认目录比 拒绝执行好,那本来就是没迁移过的安装的位置。 清完必须 `invalidateMemory(api)`:面板上每张列表都是 memo 过的,只删文件的话,被删掉的 文件夹和标签还会从内存里一直答到进程重启为止。 `documents` 每项是 `{ removed, bytes }`(第五份被删的是派生缓存 `contentIndex`)。文件被扫描器或索引器占着删不掉时 `removed: false` 而不是抛错——摘要要描述真实发生的事,`freedBytes` 也只累加真的回来了的字节。 ### 4.13 全文检索 | 路由 | body | 返回 | |---|---|---| | `GET /search/content` | `?q=` | `{ query, ready, indexed, total, hits }` | 面板里 会话名 / 标签 / 备注 三个字段本来就在列表行上,读一遍内存就有答案;**会话内容**只在会话日志里 ——本机 63 份日志、解压后约 100 万字符正文,全量读一次 6.5 秒。所以这里不是每次按键读文件,而是一个 索引(`lib/sources/content.js`):内存里按 `logPath | sizeBytes | activityAt` 判定某份日志要不要重读, 逐份构建、每份之间让出事件循环(一次最多 `budgetMs = 700`),并且同一时刻只允许一个构建在跑。 索引里装的是**说的话**:用户消息与助手回复的文字(`collectTurns` 的 `role` 为 `user` 与 `assistant` 两种回合)。工具调用的参数与输出、思考过程、DSH 每轮注入的上下文、压缩摘要都不进 索引——它们不是"谁说的",而是"系统做了什么",混进来会让搜一个函数名命中一百个无关会话。 `ready: true` 之后搜不到,就是这份日志里没人说过。 - `ready: false` = 索引还没建完,`indexed / total` 是进度;`hits` 是**已经建到的那部分**的答案。 冷启动的第一次搜索先给一半答案,比转 6 秒钟好;客户端 240ms 防抖,未就绪时每 700ms 续查一次。 - `hits` 每项 `{ id, count, snippet }`:`count` 数的是出现次数(上限 999),最多 200 个会话, `snippet` 取命中前后 30 / 70 字符、折成一行、两端带省略号。 - 检索是**纯读**:不产生 revision,读不出的日志只记一次空文本、不无限重试。 索引总是落盘一份(`content-index.v1.json`),进程重启后不必重读日志;它没有开关(m01916 去掉了),重建与删除见 §4.14。 缓存 payload 带 `version`(当前 2):v1 只存了用户那一半正文,签名(`logPath|size|activityAt`)却一样, 留着它就会永远回答"没搜到"而不自愈,所以版本一升旧条目即被忽略并就地覆盖。文件名里的 `.v1` 是槽位 不是版本号,改名只会把旧文件变成垃圾。 搜索的**位置**与字段是两件事,两者都在客户端的「搜索范围」弹窗里选:**位置**是「所有位置」(缺省) 或「当前目录」,**字段**是会话名 / 标签 / 备注 / 会话内容。缺省搜整个库——站在任何目录里搜,答案都 不该是"这个目录里没有";只有用户明确选了「当前目录」,才收窄到脚下这一层(文件夹列表也一起收窄, 不再把命中的兄弟目录列出来)。回收站按自己的行回答,不受位置影响。 ### 4.14 搜索索引(缓存 / 重建 / 删除) | 路由 | body | 返回 | |---|---|---| | `GET /search/index` | — | `{ ...state, cache, building }` | | `POST /search/index/rebuild` | — | `{ ...state, cache, building }` | | `POST /search/index/clear` | — | `{ freedBytes, sessions, cache, building }` | 对应设置文档(`lib/domain/settings.js`)里的一个开关,缺省值就在 `defaultSettings()`: | 键 | 缺省 | 含义 | |---|---|---| | `search.indexIdle` | `false` | 允许空闲时自动建立:`bootstrap` 之后等 `idleDelayMs = 1200`,再按 `idleSliceMs = 250` 的小步推进 | 索引文件本身**没有**开关。1.0.6 及更早的文档里有个 `search.indexCache`,它控制的缓存现在恒开: 派生文件删掉只花一次重建,而"不写缓存"这条路只会让每次启动都重读一百多 MB 日志。旧文档带来的 `indexCache` 在 `normalizeSettings` 里按未知键丢弃,不报错也不生效。 - `state` 是 `{ indexed, total, pending, ready }`,与 `GET /search/content` 同源;`cache` 是 `{ enabled, bytes, exists, sessions }`(`bytes` 是磁盘文件的真实大小,`exists` 只说明文件在不在); `building` 是后台构建器的忙闲。 - `rebuild` 先清空内存与磁盘条目(旧缓存不再命中任何日志),再从头构建;构建期间搜索照常可用, 只是答案逐步变全。`clear` 让构建器停下、删文件、清内存,`freedBytes` 只累加真正回来了的字节。 - `PUT /settings` 只在 `search.indexIdle` 为真时 `indexBuilder.resume()`:那是"现在就慢慢建"的 意愿表达,关掉不代表要删掉已经建好的文件——删文件是 `POST /search/index/clear` 与清除数据的事。 - 缓存条目按 `path\u0000sizeBytes\u0000activityAt` 组成的签名判失效,文件里写着 `version: 2`; 版本不符就当没有,不做迁移。文件损坏、临时目录不可写都只让 `flush` 返回 `{ written: false }`, 搜索仍然可用——缓存是加速手段,不是正确性的前提。 版本 2 之前只存了用户说的话(`turnsOf` 那时读不到 `data.message`,助手那一半从来没进过索引), 签名不变会让"搜不到助手回复"永远不愈合,所以版本号本身就是那一刀的开关。 ### 4.15 AI 字段 产物是**标题、描述、标签**三个字段,不是一段摘要文字(用户 m01823:「总结就是让你总结标题 描述 标签」; m01826:「不输出摘要,摘要不是产物」)。三个字段各自独立,`settings.summary.fields` 决定要哪几个。 | 路由 | body / query | 返回 | |---|---|---| | `GET /summaries` | — | `{ sessions, spend, pending, revision, choice }` | | `GET /summarize/catalog` | `?refresh=1` 跳过 60 秒的目录缓存 | `{ providers, failures, at }` | | `GET /summarize/state` | — | 队列状态,见下 | | `POST /summarize` | `{ ids, titles, lang }` | 队列状态 | | `POST /summarize/pending` | `{ lang }` | 队列状态 + `{ candidates, skipped }`;没活干时 `409 nothing-pending` | | `POST /summarize/cancel` | — | 队列状态(已完成的保留) | | `POST /summaries/:id/apply` | `{ field: "title" \| "description" \| "tags", tags?: string[] }` | `{ revision }` | | `DELETE /summaries/:id` | — | `{ revision }` | `state`(`lib/host/summarize.js` 的 `state()`)是客户端唯一要知道的东西: ```jsonc { "running": true, "total": 12, "done": 3, "failed": 0, "summarized": 41, "current": { "id": "session-…", "title": "重构导出", "startedAt": 1793000000000 }, "lastError": "", "lastFailureId": null, "startedAt": 1793000000000, "finishedAt": null, "spend": { /* 同 summaries.v1.json 的 spend */ } } ``` - 模型目录来自**宿主自己那一套**:`catalogFromController()` 读 `sessionController.modelCatalog()` (与 dsh-orb 同一个来源、同一份 `groups` 形状),profile 没有这个服务时回退到直连 `llm.listProviders()` / `listModels()` / `resolveModelInfo()`。单条 provider 失败只进 `failures`, 不整体失败;缓存 60 秒,`?refresh=1` 跳过。 - `POST /summarize` 在**入队之前**先解析模型路由(`pickRoute`):profile 里没有 `llm` 服务、或选中的 模型已经不在目录里而目录又空着,一律 `501 llm-unavailable`,不会先答应再一个个失败。已经在队列里 或正在写的 id 被跳过,所以重复点不会重复计费。 - 队列是单飞的:一次只发一个请求(`pump()` 里的 `while`),失败的会话记进 `lastError` 后继续下一个, 一条失败不拖垮整批。`cancel` 清掉未开始的部分并 abort 手上那一个,**已经写好的字段保留**。 - 每次尝试都落账(`recordSpend`),包括没有 `usage` 的失败:它在 `runs` 里而不在 `totalTokens` 里, 但少了它,累计数就成了伪装成总数的下限。 - 一次生成最多两次尝试:第一次的回答解析成 JSON 后若缺少要的字段(或根本不是 JSON 对象),重来一次, **两次都记账**;两次都失败则 `422 ai/bad-answer`,一个半成品都不写(半成品会让这个会话永远不再重试)。 - 送进模型的是 `transcriptOf()` 拼的对话正文——只有用户与助手的发言,思考过程(`turn.reasoning`)与 工具调用/输出(`turn.tools`)从不读;超出 `PROMPT_BUDGET_CHARS = 12000` 时保留头 55% 与尾 45%, 中间插一行省略说明。会话标题、备注、标签**不再**喂回模型(那是它要写的东西,喂回去只会让旧名字活下去)。 - 请求本身**不带任何工具**(`tools: []`)、不设 `maxTokens`、只带本插件自己的 `system`:DSH 的 agent 提示词不会混进来(pi-ai 适配器的 `splitSystemPrompt()` 只拼 `options.system` 和请求里的 system 消息), 而推理模型的第一块是 `reasoning` 而不是文字——那是正常输出,照样继续等 `text-delta`。不设输出上限 是因为思考也算进同一个预算,小上限买到的是空正文(`dsh-pet` 的 `whisper.ts` 记了同一个坑)。 调用带 `sessionId: "session-navigator"` 这个固定身份(有的 provider 按身份路由,缺了会 400), 它不是真实会话 id,检查点中间件查不到这个会话就直接放行,不会往任何历史里写东西。 - `POST /summaries/:id/apply` 是唯一会改动别的文档的路由,三个字段三种语义,都只走一次点击: `title` 走 `renameSession()` 改名;`description` 用 `mergeNote()` **追加**到用户自己的备注后面 (备注是用户的话,模型的句子只能是补充,重复提交不会贴两遍);`tags` 只并入 `body.tags` 里勾选的 那几个(`pickTags()` 与记录取交集,勾选的是过期值时回 `409 nothing-to-apply`,绝不退化成"全并"), 并把它们写进标签库——下次生成时它们就成了"已有标签"。 生成从不自动应用:名字改成什么由用户决定,标签库也不该被模型自己塞满。 - 模型被要求优先使用已有标签:`existingTags` 读 `tagVocabulary()`,最多 40 个进系统提示词; 回答里的标签再由 `alignTags()` 按现有写法归一("导出 " → "导出"),库里有词就不再造同义词。 - `DELETE /summaries/:id` 只删记录,不动花费账本:钱已经花掉了,账本记的是事实。 - 两个地方都按会话 id 的语法(`domain/annotations.js` 的 `isAnnotationId`)过滤:`start()` 丢掉不是 会话 id 的入队项,`DELETE` 对不是会话 id 的路径参数回 `400`。记录是以会话 id 为键的普通对象,键写成 `__proto__` 会让之后每一次 `sessions[id]` 查询都命中,整库看起来都"已经写完";`normalizeSummaries()` 在读盘时也按同一条规则丢掉这类键。 - `GET /summaries` 的 `revision` 是 `summaries.v1.json` 自己的修订号,与 `tree` / `settings` 无关; 客户端 `storeOf()` 对 `/summar…` 前缀返回 `null`,所以这些请求不带任何 `If-Match`。 ## 5. 客户端怎么用 `lib/client.js` 里 `storeOf(path)` / `revisionOf(path)` 负责把响应塞进订阅式 store, 所有写请求都带 `If-Match`。客户端不解析 `error` 文案,只按 `code` 分支; `revision-conflict` 的处理是重新 `GET /bootstrap?refresh=1` 再让用户重试, 设置页同理——它重读 `GET /settings` 并采用新的 `revision`,否则每个控件都会带着 同一个陈旧的 `If-Match` 一直 409(`lib/client.js` `SettingsSection.save`)。 ### 5.1 列表里的状态 每一行只有一颗点加一句字,档位与 DSH 自己的侧栏完全一致,不额外发明任何一档 (DSH 的实现:`packages/client/ui-workspace/src/client/rows/Rows.tsx` `sessionStatuses`): | 顺序 | 文案 | 点 | 什么条件 | |---|---|---|---| | 1 | 已删除 | 无 | 在插件的回收站里。这不是存活判断,是"这个载荷为什么打不开" | | 2 | 等待审批 / 计划待审 / 等待回答 | 黄 | DSH 的 `pendingInteraction.kind`,只认这三个值,最高优先级 | | 3 | 进行中 | 蓝 | DSH 的 `running`;服务不在时回落到插件从宿主读到的同一个 `running` | | 4 | {n} 个子智能体运行中 | 蓝 | 本行下面还有正在跑的子会话(子行是 `isSubagent` 且 `parentSession` 指向本行) | | 5 | 已完成 | 绿 | DSH 的 `completionUnread`:那一轮结束在别的视图里,本视图还没看过 | | 6 | 空闲 | 无 | 以上都不是 | 2、3、4 可以同时成立,DSH 就把它们并成两颗点;插件只画一颗,所以按上表取最靠前的一条。 状态的来源是客户端服务 `uiSession.sessionStatus`(`HostObservable>`, `packages/client/ui-session/src/client/index.ts`),插件用 `useSyncExternalStore` 订阅它, 所以 DSH 的侧栏变了,面板跟着变,不需要等下一次 `/bootstrap`。宿主没提供这个服务时整张表退化成 "只看 `running`",不会报错。 已被删掉的两档不再回来:**已中断**(从日志尾部推断"一轮没关"——一次崩溃和一条收尾事件从外面看 一模一样,DSH 又在内存里判断存活,结果把正常结束的会话标成了中断)与**异常** (`formatVersion !== 4`——DSH 认为那是需要迁移的旧载荷,不是故障)。