# Agent Note: Web 历史会话搜索

Status: implemented

[English](2026-07-27-web-session-search.md) | 中文

## 问题

Web 侧边栏会展示会话标题及其 Workspace 归属，但无法根据只出现在消息中的词语检索历史对话。在浏览器中扫描历史记录，需要附加或加载每个会话，重复实现现有的索引搜索服务，也会让冷态持久化会话的检索既缓慢又容易遗漏。产品还需要一条可预测的故障路径：派生索引不可用时，不得抹去客户端能够在本地计算出的标题匹配结果。

## 决策

Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据库挂载 [`@deepseek-ai/dsh-session-query-sqlite`](../../../../packages/session-query/session-query-sqlite/README.md)。服务启动时处于 ACTIVE 状态，而其 `node:sqlite` 模块与连接私有句柄分别要到首次内容查询才会导入和打开。这让 Node 22 的启动输出在使用搜索前不会出现 SQLite 实验性警告，但并不承诺在首次搜索导入该模块时抑制警告。每个服务实例都独占自己的索引，因此并行 CLI 或 Web 调用可维持 SQLite 后端的单一所有者约定，又不会留下进程级派生文件。数据库从空状态启动，并在该首次查询时惰性对齐实时会话与持久化会话。它仍是与规范 JSONL 持久化相互独立的可丢弃派生索引。

宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合，向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项，并持续消费提供方分页，直到获得 20 个已授权会话及一个前瞻项，或结果流耗尽。首个提供方页面请求 20 个命中；如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`，页面大小会依次折半为 10、5、2、1，并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验，其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点；宿主与传输 schema 共用协议边界及码点安全的截断辅助函数，而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外，可避开可移植变量上限，同时保持全局排序。响应仍只有一个有界页面；`hasMore` 会指示 UI 提示用户缩小查询范围，而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标，然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制（因此最多检查 2,000 个命中）；如果某页命中数超过其请求的上限、续传游标重复，或用尽该调用预算后结果流仍未耗尽，都会直接返回 `internal` 业务错误，不返回部分结果。载体信号会取消已被取代的工作，包括持久化列表枚举、分批受限执行的冷会话元数据 stat，以及每一次提供方调用；即使同时收到上限拒绝或陈旧拒绝，也以取消为准。查询服务缺失或索引／查询故障无法恢复时，仍作为业务错误处理，不会修改规范会话存储。

[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文；输入框及防御性请求路径会移除 NUL，将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配，在 250 ms 防抖后发起内容请求，在查询变化时中止前一请求，并忽略陈旧的完成结果。它先按新近程度排列本地匹配，再合并由后端排序且仅匹配内容的结果，按会话 id 去重；无论常规分组模式如何，最终都渲染为扁平列表。每一行显示标题、Workspace，并在存在时显示一行摘要片段。选择某一行只会打开对应会话，并保留查询条件；不会跳转至确切事件。

结果上限是单一协议常量，而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边，`SessionRuntime.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它，必须显式扩展 sessions 域的对外面：`ISessions`（即注入为 `ctx.sessions` 的那个面，也因此是测试运行时的 sessions 替身必须实现的面）在该上限旁声明了搜索动作。连接 handle 不携带它：逐连接字段会暗示该上限随传输层变化或由服务端协商，而 schema 固定的 `max` 恰恰禁止这一点，并且会让同一事实在同一模块内拥有两处归属。

内容匹配沿用 SQLite 后端经过规范化的字面 token／短语语义。共享语义投影会排除推理（reasoning）块，因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回；派生索引的 schema 版本会随之前进，使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理，此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是，`unicode61` 分词器可能将一段连续中文视作单个 token，因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。

## 故障与可见性约定

搜索绝不会扩大会话可见范围：没有可供服务的 cwd 的冷会话会被排除，原因与它们不出现在 `session.list` 中相同；只有 id 位于这条基线中的提供方命中才能离开宿主。被遮蔽事件和纯日志事件、消息内容之外的工具事件、错误、待办事项及其他追踪记录都不会产生 UI 命中结果。

首个或后续内容请求仍在处理期间，UI 会保留即时元数据匹配结果，并显示历史搜索状态。如果后端失败，这些行会保持不变，并显示警告说明内容搜索不可用。合并后没有任何行时，界面会显示明确的空状态。候选行超过 20 条时，界面会提示用户缩小查询范围。

## 曾考虑的替代方案

- **在浏览器中扫描每个会话的历史记录**：不予采纳，因为这会让 UI 承担传输与折叠开销；除非加载冷态日志，否则还会漏掉这些日志；并会重复实现已经由 `ctx.sessionQuery` 负责的语义提取与源对齐。
- **首版即加入 trigram 或模糊搜索**：不予采纳，因为这会改变索引大小、排序、短查询行为与产品预期。trigram 本身也无法解决双字查询。首版沿用现有后端约定，将召回扩展留作另一项基于度量结果的决策。
- **返回事件地址并跳转至确切匹配位置**：本版不予采纳，因为对话虚拟化与稳定的事件导航需要单独的 UI 约定。会话级导航本身已有价值，无需让搜索与这项工作耦合。
- **在侧边栏公开游标分页**：不予采纳，改为固定显示前 20 条结果并提示缩小查询范围；这样可使交互与取消状态保持有界。

## 后果

无需预先打开，即可检索到历史持久化对话，同时宿主仍只保留一条可见性边界和一套语义索引实现。即时本地结果掩盖了大部分请求延迟，取消机制可防止已作废查询重新渲染列表，后端故障则会降级为内容搜索尚不可用时已有的行为。

首次内容查询可能耗时更长，因为它要先导入并打开 SQLite，再承担惰性对齐的开销。搜索质量采用 token／短语召回，而不是模糊召回或任意子串召回，并受上述连续中文限制。结果粒度为会话，最多 20 条，不支持分页，也不能跳转到具体消息。如果有效但选择性极差或反复陈旧的提供方尝试未能在 100 次调用内完成，系统会进入仅保留元数据匹配的故障路径，而不是无限制地继续处理。

## 测试

宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定；语义提取测试与 SQLite／fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物，在移除环境级警告抑制并采用隔离的临时 home／提供方环境后，以普通 Node 启动随产品交付的 `dsh web`／`AppCLIEntry` 组合，等待启动完成并稳定，再沿随产品交付的信号路径对其执行 dispose（资源释放）。fixture（测试前置数据）、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖／中止／陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时，播种一段尚未打开的持久化对话，通过 SQLite 索引按可见消息内容找到它，捕获侧边栏结果，打开该会话，并验证查询条件仍然保留。
