# DSH Session Query 设计与实现
> 对象:DeepSeek Harness(`dsh`)的会话历史检索能力族
> 代码位置:`packages/session-query/`
> 适用版本:仓库当前 checkout(`@deepseek-ai/dsh-tool-session-query@0.1.3-alpha.1`)
---
## 1. 定位与能力边界
Session Query 解决的问题是:**在不破坏会话日志、不把会话变成 live 状态的前提下,对历史会话做精确读取、关系追踪和全文检索,并把其中一部分能力以模型工具的形式暴露出来。**
它有三层,且严格分离:
| 层 | 包 | 职责 |
|---|---|---|
| 模型工具层 | `@deepseek-ai/dsh-tool-session-query` | 5 个只读工具、参数校验、工作区授权、错误脱敏、文本呈现 |
| 服务层 | `@deepseek-ai/dsh-session-query` | 与后端无关的会话读取、过滤、血缘/事件追踪、语义文档抽取 |
| 索引后端层 | `@deepseek-ai/dsh-session-query-sqlite` | SQLite FTS5 派生索引、增量对账、排序、分页、snippet |
另有 `@deepseek-ai/dsh-session-log-export` 提供 Web `/export` 下载会话 ZIP,与检索共用底层读取思路,但不是模型工具。
关键定位:
- **只读**:不写入 canonical 会话日志。
- **live 优先**:同一 session id 同时存在于内存和持久化时,永远以内存中的 live 会话为准。
- **workspace 授权**:跨会话访问要求 `cwd` 完全字符串相等。
- **opt-in**:官方默认宿主组合不挂载工具包,也不默认打开搜索索引。
- **模型侧无 cursor**:分页在工具内部完成,模型只看到结果或“达到上限”。
---
## 2. 包结构与依赖
```text
packages/session-query/
├─ session-query/ # 服务契约 + 后端无关实现
│ ├─ src/index.ts # abstract SessionQueryEngine
│ ├─ src/types.ts # 全部公共类型
│ ├─ src/config.ts # 配置默认值与错误码
│ ├─ src/corpus.ts # live/persisted 语料解析
│ ├─ src/cold-read.ts # 冷日志一次性读取 + 中断回合补齐
│ ├─ src/observation.ts # 精确快照 + 冷观察 LRU 缓存
│ ├─ src/documents.ts # 事件 -> 语义文档 + surface 分类
│ ├─ src/extraction.ts # 每种事件的可检索文本
│ ├─ src/filters.ts # 与后端无关的过滤谓词
│ ├─ src/tracing.ts # 血缘与事件关系追踪
│ ├─ src/cursor.ts # 不透明 cursor 品牌类型
│ └─ src/sources.ts # header 一致性校验
├─ session-query-sqlite/ # FTS5 后端
│ ├─ src/schema.ts # 表结构、版本、打开与保护
│ ├─ src/query.ts # 请求规范化、SQL 谓词、snippet
│ └─ src/index.ts # 对账、索引写入、搜索执行、cursor
├─ tool-session-query/ # 模型工具
│ ├─ src/index.ts # 插件注册 + 配置 + system prompt
│ ├─ src/input.ts # JSON Schema + 参数规范化
│ ├─ src/operations.ts # 5 个操作编排
│ ├─ src/workspace-access.ts # 调用者身份、授权、标题读取
│ ├─ src/service-boundary.ts # 服务调用与错误脱敏
│ └─ src/presentation.ts # 纯文本输出与调用卡片
└─ session-log-export/ # Web /export 命令(旁路)
```
依赖方向:
```text
tool-session-query ──► session-query ◄── session-query-sqlite
│ │
└──► @deepseek-ai/dsh-tools / dsh-agent / dsh-session-projection
│
└──► @deepseek-ai/dsh-session
@deepseek-ai/dsh-session-persistence
@deepseek-ai/dsh-session-title
```
`SessionQueryEngine` 只 `inject = ['sessions']`;持久化和投影都是**可选服务**,用 `ctx.inject` / `ctx.get` 动态绑定,因此没有持久化后端时精确读取仍能工作,只是搜不到冷会话。
---
## 3. 总体架构
```mermaid
flowchart TD
M[模型] -->|tool call| T[tool-session-query]
T --> A[workspace-access
身份 + cwd 授权]
T --> O[operations
编排 + 内部翻页]
O --> B[service-boundary
调用 + 错误脱敏]
B --> Q[ctx.sessionQuery]
Q --> E[SessionQueryEngine
精确读取/过滤/追踪]
Q --> S[SqliteSessionQueryEngine
searchSessions/searchEvents]
E --> C[SessionCorpus]
C --> L[ctx.sessions
live]
C --> P[ctx.sessionPersistence
persisted]
S --> R[对账 _reconcile]
R --> C
S --> D[(SQLite FTS5
persisted_docs + temp.live_docs)]
```
一次搜索的端到端链路:
1. 工具层从 `exec.agent` 取调用者 session,构造 `cwd` 过滤。
2. 工具层规范化 query/filters,调用 `ctx.sessionQuery.searchSessions/searchEvents`。
3. SQLite 后端串行化请求,打开索引,执行对账(把 live 与 persisted 的最新状态同步进派生索引)。
4. 执行 FTS5 查询、过滤、排序、`limit + 1` 预读分页。
5. 工具层在内部循环翻页,跳过未授权项,直到收集到 `maxSearchResults` 或没有下一页。
6. 工具层读取标题、裁剪越权血缘、渲染纯文本。
---
## 4. 核心数据模型
### 4.1 会话记录
```ts
interface SessionRecord {
header: SessionHeader // version/id/createdAt/cwd/parentSession/isSeeded/delegationDepth/agentPreset
live: boolean // 当前存在于 ctx.sessions
persisted: boolean // 当前持久化后端列出
}
```
`listSessions()` 的顺序是 `createdAt` 降序,再按 id 升序,保证确定性。
### 4.2 事件记录与语义文档
```ts
interface SessionEventRecord {
sessionId: SessionId
seq: SessionSeq
type: SessionEventType
time: number // Unix epoch ms
surface: 'current' | 'shadowed' | 'log-only'
}
interface SessionEventSearchDocument extends SessionEventRecord {
text: string // 抽取后的语义文本
}
```
- `current`:surface fold 后的当前节点。
- `shadowed`:被后续替换事件遮蔽。
- `log-only`:不在模型 surface 中,但仍是日志事件。
### 4.3 搜索命中
```ts
interface SessionEventSearchHit extends SessionEventRecord {
snippet: string
}
interface SessionSearchHit extends SessionRecord {
bestMatch: SessionEventSearchHit // 该会话最强命中
}
interface SessionSearchPage {
items: T[]
nextCursor?: SessionSearchCursor
}
```
`searchEvents` 额外返回 `session: SessionHeader`,它与 `items` 来自同一个索引 generation,避免分页过程中目标会话被替换导致结果错位。
### 4.4 不透明 cursor
```ts
type SessionSearchCursor = Branded<'SessionSearchCursor'>
```
真实 payload 只在 SQLite 后端内部:
```ts
interface CursorPayload {
version: 1
instance: string // 进程内唯一,防跨实例重放
scope: 'sessions' | 'events'
fingerprint: string // 规范化请求指纹
generation: string // 相关语料 generation
offset: number
}
```
模型永远看不到它;工具层用 `collectPages` 内部消费。
---
## 5. 事件文本抽取
`extractSessionEventText`(`extraction.ts`)决定“什么内容可被搜到”。这是检索质量的核心。
| 事件类型 | 抽取内容 |
|---|---|
| `user/message` | `content` 中 `text` 块、`tool-call` 的 name/arguments、`tool-result` 的递归内容 |
| `assistant/message` | 同上 |
| `tool/call` | 工具名 + 参数 |
| `tool/result` | 结果内容 + 错误 name/code |
| `todo/write` | 每条 todo 的 status + content |
| `turn/end` | 错误消息 / aborted / max-tokens / interrupted |
| `turn/start`、`step/start`、`step/end`、`assistant/attempt`、`request/header` | 空(结构性事件) |
| 未知事件类型 | 空 |
明确排除:
- `reasoning` 块不进入索引。
- 未知 `ContentBlock` 不因为“里面恰好有字符串”就可搜。
- 结构性边界和 request envelope 不进入索引。
空文本事件不会生成文档,因此不会出现在搜索结果中。
---
## 6. 语料解析:live 优先
`SessionCorpus`(`corpus.ts`)是“逻辑会话语料”的唯一入口。
### 6.1 listSessions
1. 列出持久化后端所有 header,构造 `{live:false, persisted:true}`。
2. 遍历 `ctx.sessions.list()`;同 id 覆盖为 live,并做 header 兼容性校验。
3. 按 `createdAt desc, id asc` 排序。
### 6.2 load(精确读取)
```text
ctx.sessions.get(id) 命中?
├─ 是 → snapshotLive:结构化克隆 header + 事件,立即返回
└─ 否 → 无持久化? → notFound
有持久化 → list 找到 header → readColdSessionLog
→ 读取期间会话被 attach? → 改用 live 快照
→ 校验 header 兼容 → 返回冷快照
```
“已知 live 目标绝不查持久化”是有意设计:可选后端的故障不能导致当前内存历史不可读。
### 6.3 cold read
`readColdSessionLog`:
1. `persistence.open(sessionId, 'read')`
2. `handle.read(0, undefined)`
3. `handle.close()`
4. 追加 `interruptedTurnClosers(events)`
最后一步让“写入者中途崩溃”的日志在内存中表现为平衡 transcript,**不回写存储**。
### 6.4 projectMany
批量读取标题等场景使用:先解析所有 live,再对 persisted 使用 `persistedReadConcurrency`(默认 4)并发读取;单个失败被隔离为 `rejected`,取消则整体抛出。
---
## 7. 精确观察与冷缓存
`SessionObservationReader`(`observation.ts`)提供“一次精确、不可变的快照 + 租约”。
- live 路径:直接取 `session.snapshotEvents()`。
- 冷路径:
1. `persistence.stat` 取 revision。
2. 若缓存命中同 persistence 实例 + 同 revision,复用。
3. 否则冷读日志,`ctx.sessions.prepare(...)` 构造一个**未发布**的 Session(不会进入 store)。
4. 缓存条目按 LRU 淘汰,`preparedSessionCacheSize` 默认 5。
5. 被租约 pin 住的条目不会被淘汰;`retain()` 增加引用,`Symbol.dispose` 减少引用。
这让“读取冷会话”不需要把它变成 live,也不需要每次重读日志。
---
## 8. 过滤系统
`filters.ts` 是纯函数层,服务层和后端层共用。
语义:
- 过滤数组之间 **AND**。
- 同一子句的 `values` 内部 **OR**。
- 范围闭区间。
会话过滤:
| kind | 含义 |
|---|---|
| `id` | session id 列表 |
| `cwd` | 工作目录,含 `null` |
| `created-at` | 创建时间范围 |
| `parent` | 直接父会话,含 `null` |
| `availability` | `live` / `persisted` 至少一个 |
事件过滤:
| kind | 含义 |
|---|---|
| `seq` | 事件序号范围 |
| `time` | 事件时间范围 |
| `type` | 事件类型 |
| `surface` | `current` / `shadowed` / `log-only` |
| `text` | **字面**、大小写不敏感、空白灵活的正则扫描 |
`text` 过滤器的实现:
```ts
const pattern = trimmed
.split(/\s+/u)
.map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
.join('\\s+')
return new RegExp(pattern, 'iu')
```
即:转义正则元字符、把空白替换成 `\s+`、用 `iu` 标志。它是**子串级**匹配,和 FTS 的 token 匹配不同。
注意:`SessionEventMetadataFilter` 显式排除了 `text`,所以 FTS 后端不接受 `text` 过滤;`text` 只用于服务层 `filterEvents` 的扫描路径。模型工具也没有暴露它。
---
## 9. 关系追踪
`tracing.ts` 基于一次 canonical `foldSurface(events)`:
### 9.1 事件追踪 `traceEvent`
返回:
```ts
{
target: SessionEventRecord
replacedBy?: SessionSeq
replacementChain: SessionSeq[] // 沿 replacedBy 一直向上
replacedEventSeqs: SessionSeq[] // 被 target 直接替换的事件
sourceEventSeqs: SessionSeq[] // target 直接引用的源事件
derivedEventSeqs: SessionSeq[] // 后续直接引用 target 的事件
}
```
### 9.2 会话血缘 `traceSession`
1. 从 target 沿 `parentSession` 向上收集祖先,检测环。
2. 从全量 records 构建 `childrenByParent`,按 `createdAt, id` 排序。
3. 迭代式构建后代树(避免递归深度问题)。
4. 如果父链走出可见语料,返回 `complete:false` 和 `unresolvedParentId`;否则返回 `root`。
工具层再把越权的祖先/后代替换成 `[outside workspace boundary]` / `[outside workspace subtree]`,不泄露隐藏 id。
---
## 10. SQLite FTS5 后端
### 10.1 数据库结构
`SESSION_QUERY_SQLITE_SCHEMA_VERSION = 8`
`SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851`
持久表:
```sql
search_state(singleton, global_generation)
persisted_sessions(
id, version, created_at, cwd, parent_session,
seed_length, delegation_depth, agent_preset,
revision, generation
)
persisted_docs USING fts5(
text,
session_id UNINDEXED,
seq UNINDEXED,
type UNINDEXED,
time UNINDEXED,
surface UNINDEXED,
codepoint_length UNINDEXED,
tokenize = 'unicode61'
)
```
连接级临时表:
```sql
temp.live_sessions(... fingerprint, persisted, generation ...)
temp.live_docs USING fts5(...)
```
设计要点:
- 这是**派生索引**,不是 canonical 存储;路径必须独立,不能指向 session persistence 数据库。
- 打开时校验 `application_id` 和用户表集合,拒绝外来数据库。
- 版本不匹配时原地重置派生表。
- 新建文件在 POSIX 上使用 `0600`,目录 `0700`。
- live 索引放 TEMP 表:进程结束即消失,不需要持久化未 checkpoint 的 live 事件。
### 10.2 索引打开策略
`openAt`:
- `startup`:服务激活时打开。
- `first-search`:第一次搜索时懒加载 `node:sqlite`。
- `never`:完全不导入 SQLite;`searchSessions/searchEvents` 抛 `SESSION_QUERY_SEARCH_DISABLED`,但精确读取/过滤/追踪仍可用。
### 10.3 对账(reconciliation)
每次搜索前都会 `_reconcile`,把索引同步到最新语料。核心步骤:
1. `_serialized`:所有搜索操作通过 `_tail` 串行队列,避免 FTS5 游标和事务并发问题。
2. 读取当前 `persisted_sessions` 与 `temp.live_sessions` 索引行。
3. `_observeStable` 最多尝试 2 次:
- `persistence.list()` 前快照。
- 对 revision 变化且未被 live 遮蔽的会话做冷读并生成文档。
- `persistence.list()` 后快照;前后必须一致。
- 检查 persistence 服务绑定是否在过程中变化。
- 检查 live session 集合是否变化。
4. 计算差异:
- persisted 新增/变化/删除
- live 新增/变化(fingerprint 或 persisted 标志变化)/删除
5. 单事务 `BEGIN IMMEDIATE` 写入:
- 删除旧 persisted/live 行
- 插入新 header 与文档
- 更新 `global_generation`
- `COMMIT`
6. generation 规则:
- persisted 变化 → main generation + 1
- 每次 live 替换 → local generation = max(local, main) + 1
- 指针变化或写入 → `_globalGeneration += 1`
live 的 fingerprint:
```ts
sha256(JSON.stringify({ header, inheritedEventCount, events }))
```
因此 live 会话只要事件有任何变化,就会重写自己的索引行。
### 10.4 搜索 SQL
`selectedDocumentsSql()` 用 `UNION ALL` 合并两个来源:
```sql
-- persisted
FROM persisted_docs pd
JOIN persisted_sessions ps ON ps.id = pd.session_id
WHERE persisted_docs MATCH ?
AND ? = 1
AND NOT EXISTS (SELECT 1 FROM temp.live_sessions ls WHERE ls.id = pd.session_id)
UNION ALL
-- live
FROM temp.live_docs ld
JOIN temp.live_sessions ls ON ls.id = ld.session_id
WHERE live_docs MATCH ?
```
`NOT EXISTS` 实现 **live 遮蔽 persisted**,所以同一会话不会出现两份结果。
`match_count` 不是 FTS 的 `bm25()`,而是通过 `highlight()` 插入的标记计数:
```sql
(length(marked_text) - length(replace(marked_text, marker, ''))) / marker_bytes
```
### 10.5 排序
事件搜索:
```sql
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
```
会话搜索:
```sql
ROW_NUMBER() OVER (
PARTITION BY session_id
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
) AS event_rank
... WHERE event_rank = 1
ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC
```
即:命中次数多、文档短、时间新的事件优先;跨会话再取每个会话的第一名。
### 10.6 分页与 cursor
- 查询 `limit + 1` 行,用多出的一行判断 `hasMore`。
- `nextCursor` 是 base64url(JSON),绑定:
- 实例 UUID
- scope(sessions/events)
- 规范化请求指纹
- generation
- offset
- cursor 失效分类:
- 不属于该请求/实例/scope → `SESSION_QUERY_INVALID_CURSOR`
- generation 变化 → `SESSION_QUERY_STALE_CURSOR`
### 10.7 snippet
`makeSnippet`:
1. 去掉 `highlight()` 插入的 `\uFDD0` / `\uFDD1` 标记。
2. 空白折叠为单个空格,去掉首尾空格。
3. 文本不超过 `snippetChars` 直接返回。
4. 否则围绕第一个命中位置开窗:起点约为 `matchStart - maxChars/3`,两端加 `…`。
5. 按 Unicode code point 截取,不会切断代理对。
---
## 11. 模型工具层
### 11.1 插件注册
`tool-session-query/src/index.ts`:
```ts
export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections']
```
注册 5 个工具,并向 system prompt 注入固定一段引导文本。搜索工具带 `timeoutMs = searchTimeoutMs`(默认 30s);追踪/读取工具标记 `isConcurrencySafe: () => true`,搜索工具没有标记,因为一次搜索会消费 generation 绑定的 provider cursor。
配置:
| 字段 | 默认 | 含义 |
|---|---:|---|
| `maxSearchResults` | 100 | 单次调用最多返回的授权命中数 |
| `searchTimeoutMs` | 30000 | 搜索协作式截止时间 |
### 11.2 参数与规范化
`input.ts` 定义 5 个工具的 JSON Schema:
- `session_search`:query + 会话/事件过滤
- `session_event_search`:session_id 可选 + query + 事件过滤
- `session_trace`:session_id 可选
- `session_event_trace`:session_id 可选 + seq
- `session_event_read`:session_id 可选 + seq + before/after
规范化规则:
- query:trim、空白折叠、拒绝空串、拒绝 NUL。
- seq/time 范围:非负安全整数;from ≤ to。
- 时间戳:必须是带时区的 ISO 8601,转成 epoch ms。
- `event_types` / `availability` / `surfaces` 必须非空。
- 父会话 id 去重。
### 11.3 授权模型
`workspace-access.ts`:
```text
caller = exec.agent.session
target = args.session_id ?? caller.id
```
规则:
- target == caller:直接允许。
- 否则 caller 必须有 `cwd`,且目标会话 header 的 `cwd` 必须与 caller **完全字符串相等**。
- 授权通过 `ctx.sessionQuery.filterSessions([{kind:'id'}, {kind:'cwd'}])` 完成。
- 结果返回后还会用实际观测到的 header 再校验一次(`assertObservedTargetAuthorized`),防止 TOCTOU。
标题读取失败不会泄露原因:除“未授权”外,统一显示 `untitled`,必要时附 `(title unavailable: CODE)`。
### 11.4 服务边界与错误脱敏
`service-boundary.ts` 把服务错误映射成模型安全错误:
- 取消:原样保留。
- 已知 `SessionQueryError`:映射为固定文案,例如
- `SESSION_QUERY_PERSISTENCE_FAILED` → `session history storage is unavailable`
- `SESSION_QUERY_STALE_CURSOR` → `session history changed while paging; retry the complete search call`
- 未授权:`SESSION_QUERY_TOOL_UNAUTHORIZED`
- 其他/不可打印:`SESSION_QUERY_TOOL_FAILED`
完整诊断(含 cause 链)只写内部日志 `ctx.logger.warn`,不进入模型。
### 11.5 内部翻页
`operations.collectPages`:
```text
cursor = undefined
loop:
page = request(cursor)
逐项 accept(跨会话排除 caller / 校验 header 授权)
达到 maxResults → capped = true 返回
page.nextCursor 为空 → 结束
cursor 重复 → SESSION_QUERY_INVALID_CURSOR
cursor = page.nextCursor
```
因此模型侧不需要、也无法控制 cursor/offset/limit。
---
## 12. 五个工具逐一说明
### 12.1 `session_search`(跨会话)
流程:
1. 取 caller;没有 `cwd` 直接拒绝(跨会话搜索不可用)。
2. 规范化 query。
3. 构建 session filters:`session_ids`、`created_at` 范围、`availability`。
4. 构建 event filters:seq/time/type/surface。
5. 处理 `parent_session_ids` / `include_root_sessions`:先对父 id 做授权,未授权父 id 被丢弃。
6. **始终追加 `cwd = caller.cwd` 过滤**。
7. 调用 `searchSessions`,内部翻页,跳过 caller 自身,按 header 再授权。
8. 批量读取标题与父 id 授权。
9. 渲染文本。
返回:每个会话一条最强命中,含 id、标题、创建时间、父、可用性、`seq|type|surface|time`、snippet。
### 12.2 `session_event_search`(单会话)
流程:
1. 解析 target(省略 session_id 即当前会话)。
2. `authorizeTarget`。
3. 当前会话时:
- 要求存在 active step boundary,否则 `SESSION_QUERY_TOOL_NO_CURRENT_STEP`。
- `seq_to = min(seq_to, stepStartSeq - 1)`,排除正在执行的这一步。
4. 构建事件过滤。
5. `searchEvents` + 内部翻页。
6. 返回:会话 id/标题 + 每条命中的 `seq|type|surface|time` + snippet。
其他会话不受“排除当前步骤”限制。
### 12.3 `session_trace`(会话血缘)
1. 授权 target。
2. `traceSession`。
3. 再校验 target header。
4. 逐级裁剪祖先:遇到未授权祖先即停止并标记边界。
5. 后代树投影:未授权节点替换为 `null`,渲染为 `[outside workspace subtree]`。
6. 批量读取可见 id 的标题。
7. 返回:目标信息、祖先(最近优先)、后代树。
### 12.4 `session_event_trace`(事件关系)
1. 校验 seq 非负安全整数。
2. 授权 target。
3. `traceEvent`。
4. 返回:目标事件、`Replaced by`、替换链、被替换事件、被引用源事件、派生事件。
### 12.5 `session_event_read`(事件读取)
1. 校验 seq/before/after。
2. 授权 target。
3. `readEvent`:目标事件完整 JSON + 前后窗口。
4. 窗口上限由服务配置 `readWindowMax` 控制(默认 50)。
5. 返回:目标事件 JSON(fenced code block)+ Before/After 邻居摘要。
邻居摘要使用同一套 `extractSessionEventText`,空文本显示 `(no semantic text)`。
---
## 13. 搜索语义与已知限制
### 13.1 匹配语义
- query 被 `quoteFtsData` 整体包成一个 FTS5 字面 phrase:
```ts
return `"${query.replaceAll('"', '""')}"`
```
- 因此 `OR`、`NEAR`、`*`、引号、括号都只是数据,不是查询语法。
- 分词器是 `unicode61`:
- 大小写不敏感、去除变音符号。
- 按 token 匹配,**不是任意子串**:`AI` 不会命中 `BRAID`。
- 连续 CJK 字符通常构成一个 token,搜索中文句子片段可能不命中。
- 需要真正的子串/原文扫描时,用服务层 `filterEvents(sessionId, [{kind:'text', text}])`;模型工具没有暴露这个能力。
### 13.2 结果上限
- 工具层 `maxSearchResults` 默认 100,达到后返回 `capped: true` 并提示缩小查询。
- 没有 continuation token 暴露给模型。
### 13.3 工作区语义
- 精确字符串 `cwd` 相等,不做 realpath/符号链接归一。
- caller 没有 `cwd` 时只能访问自己。
- 跨会话 `session_search` 永远排除 caller 自身。
### 13.4 索引与存储
- 派生索引损坏可重置,不影响 canonical 日志。
- 搜索索引是独立数据库,不能与 session persistence 共用路径。
- 没有持久化后端时,冷会话不可见,但 live 会话仍可搜。
- `openAt: 'never'` 时搜索禁用,精确读取仍可用。
### 13.5 并发与预算
- 所有搜索通过单进程串行队列执行。
- 外层谓词预算 14;绑定变量预算 32766。
- 对账要求前后快照稳定,最多重试 1 次。
---
## 14. 错误分类
`SessionQueryErrorCode` 是封闭集合:
```text
SESSION_QUERY_ABORTED
SESSION_QUERY_CORRUPT_SESSION
SESSION_QUERY_EVENT_NOT_FOUND
SESSION_QUERY_INDEX_FAILED
SESSION_QUERY_INVALID_CONFIG
SESSION_QUERY_INVALID_CURSOR
SESSION_QUERY_INVALID_FILTER
SESSION_QUERY_INVALID_LIMIT
SESSION_QUERY_INVALID_QUERY
SESSION_QUERY_INVALID_LINEAGE
SESSION_QUERY_INVALID_SURFACE
SESSION_QUERY_INVALID_WINDOW
SESSION_QUERY_PERSISTENCE_FAILED
SESSION_QUERY_SEARCH_DISABLED
SESSION_QUERY_SESSION_NOT_FOUND
SESSION_QUERY_STALE_CURSOR
SESSION_QUERY_SOURCE_CONFLICT
```
工具层额外错误:
```text
SESSION_QUERY_TOOL_UNAUTHORIZED
SESSION_QUERY_TOOL_MISSING_AGENT
SESSION_QUERY_TOOL_NO_CURRENT_STEP
SESSION_QUERY_TOOL_FAILED
```
映射原则:模型只看到稳定、可行动、无内部细节的文案;内部日志保留完整 cause 链。
---
## 15. 配置总表
### 15.1 `session-query` 服务
| 字段 | 默认 | 含义 |
|---|---:|---|
| `readWindowMax` | 50 | `before`/`after` 最大窗口 |
| `persistedReadConcurrency` | 4 | 批量冷读并发 |
| `preparedSessionCacheSize` | 5 | 冷观察 LRU 容量 |
### 15.2 `session-query-sqlite`
| 字段 | 默认 | 含义 |
|---|---|---|
| `path` | 必填 | 派生索引 SQLite 路径,支持 `:memory:` |
| `openAt` | `startup` | `startup` / `first-search` / `never` |
| `journalMode` | `wal` | `wal` / `delete` / `truncate` / `persist` |
| `defaultLimit` | 20 | 请求未指定 limit 时的页大小 |
| `maxLimit` | 100 | 最大页大小 |
| `snippetChars` | 240 | snippet 最大字符数(code point) |
| `readWindowMax` | 50 | 继承自服务 |
| `persistedReadConcurrency` | 4 | 继承自服务 |
| `preparedSessionCacheSize` | 5 | 继承自服务 |
### 15.3 `tool-session-query`
| 字段 | 默认 | 含义 |
|---|---:|---|
| `maxSearchResults` | 100 | 单次工具调用最大授权命中数 |
| `searchTimeoutMs` | 30000 | 搜索协作式超时 |
---
## 16. 挂载方式
工具包是 opt-in。典型组合:
```yaml
- name: '@deepseek-ai/dsh-session'
- name: '@deepseek-ai/dsh-session-persistence-jsonl'
- name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: /absolute/path/to/session-search.db
- name: '@deepseek-ai/dsh-tool-session-query'
config:
maxSearchResults: 100
searchTimeoutMs: 30000
```
要点:
- 先挂 `session`,再挂 `session-query-sqlite`(它注册 `ctx.sessionQuery` 的具体实现)。
- `tool-session-query` 依赖 `ctx.sessionQuery`、`ctx.tools`、`ctx.systemPrompt`、`ctx.sessionProjections`。
- 只想要程序化检索时,可以只挂 `session-query-sqlite`,不挂工具包。
- 只想要精确读取/追踪时,可以挂 `session-query` 的其它实现,或 `openAt: 'never'`。
---
## 17. 端到端示例
### 17.1 跨会话找某次用户输入
```json
{
"query": "登录超时",
"event_types": ["user/message"],
"created_at_from": "2026-10-01T00:00:00+08:00"
}
```
链路:
```text
tool -> buildSessionFilters/buildEventFilters
-> searchSessions(query, cwd=caller.cwd, filters)
-> reconcile -> FTS5 MATCH '"登录超时"' + type='user/message'
-> rank by match_count/document_length/time/seq
-> 每会话取 event_rank=1
-> 工具内部翻页 + 授权
-> 渲染文本
```
### 17.2 读某个命中的完整事件
先拿到 `seq`,再:
```json
{
"session_id": "sess_xxx",
"seq": 42,
"before": 3,
"after": 3
}
```
返回目标事件完整 JSON + 前后 3 条事件摘要。窗口受 `readWindowMax`(默认 50)限制。
### 17.3 追血缘
```json
{ "session_id": "sess_xxx" }
```
返回祖先链与后代树;越权部分以边界标记呈现。
---
## 18. 二次开发建议
### 18.1 增加“向任意 session 发消息”的工具
当前没有这样的模型工具。最小实现思路:
```ts
ctx.tools.register(defineTool({
name: 'session_prompt',
parameters: {
session_id: { type: 'string', required: true },
message: { type: 'string', required: true },
mode: { type: 'string', enum: ['queue', 'steer'] },
},
execute: async (args, exec) => {
// 1. 复用 workspace-access 的 authorizeTarget 做 cwd 授权
// 2. 调用 ctx.sessionController.prompt({
// requestId: ,
// sessionId: args.session_id,
// mode: args.mode ?? 'queue',
// content: [{ type: 'text', text: args.message }],
// })
// 3. 返回 accepted 确认,不等待回答
},
}))
```
注意:
- 这是**写操作**,与 session-query 的只读定位不同,建议单独成包。
- 必须复用同一套 workspace 授权,否则会绕过隔离。
- `prompt` 返回的是“已进入 inbox”的回执,不是模型回答。
- 若要 steer 正在运行的会话,需要确认目标处于 live 状态。
### 18.2 增加真正的子串搜索
模型工具目前只走 FTS phrase。若需要“用户输入原文子串”:
1. 在服务层用 `filterEvents(sessionId, [{kind:'text', text}])` 做逐事件扫描。
2. 或在 SQLite 后端增加 trigram 索引/额外列,但注意索引体积和短查询召回。
3. 若暴露给模型,需要重新设计结果上限和分页语义,因为扫描路径没有 generation 绑定 cursor。
### 18.3 自定义文本抽取
如果部署方有自有事件类型,可在 `extractSessionEventText` 的 switch 中增加分支,或在自身包里提供等价的文档构建器。注意:
- 未知事件默认不可搜是安全默认值。
- 不要把 reasoning、原始流、request envelope 纳入索引,否则会放大索引和噪声。
### 18.4 调整授权语义
当前是精确 `cwd` 相等。若要支持符号链接/大小写不敏感文件系统,应集中修改:
- `workspace-access.ts` 的 `headerAuthorized`
- `session-query` 的 `cwd` 过滤语义
- SQLite 的 `cwd` 列写入与比较
不要在单个工具里打补丁,否则不同入口的授权会不一致。
---
## 19. 设计取舍总结
| 决策 | 原因 | 代价 |
|---|---|---|
| live 优先 | 当前内存历史永远最新 | 需要 fingerprint 与遮蔽逻辑 |
| 派生索引独立库 | 损坏可重置,不危及 canonical 日志 | 多一个数据库路径要配置 |
| TEMP 表存 live | 不持久化未 checkpoint 事件 | 进程重启后需重建 |
| 单进程串行搜索 | 避免 FTS5 游标/事务并发问题 | 搜索吞吐受限 |
| 字面 phrase 查询 | 查询语法安全、可预测 | 不支持布尔/MATCH 表达式 |
| unicode61 | 索引小、支持短 token | 不支持任意子串与 CJK 分词 |
| 模型侧无 cursor | 接口简单、结果稳定 | 无法让模型主动续页 |
| 精确 cwd 授权 | 保守、可预测 | 符号链接等价路径不共享权限 |
| 错误脱敏 | 不泄露内部路径/堆栈 | 排障依赖内部日志 |
---
## 20. 参考文件索引
| 主题 | 文件 |
|---|---|
| 服务契约 | `packages/session-query/session-query/src/index.ts` |
| 公共类型 | `packages/session-query/session-query/src/types.ts` |
| 语料解析 | `packages/session-query/session-query/src/corpus.ts` |
| 冷读 | `packages/session-query/session-query/src/cold-read.ts` |
| 观察缓存 | `packages/session-query/session-query/src/observation.ts` |
| 文本抽取 | `packages/session-query/session-query/src/extraction.ts` |
| 文档构建 | `packages/session-query/session-query/src/documents.ts` |
| 过滤 | `packages/session-query/session-query/src/filters.ts` |
| 追踪 | `packages/session-query/session-query/src/tracing.ts` |
| SQLite schema | `packages/session-query/session-query-sqlite/src/schema.ts` |
| 查询/SQL/snippet | `packages/session-query/session-query-sqlite/src/query.ts` |
| 对账/搜索执行 | `packages/session-query/session-query-sqlite/src/index.ts` |
| 工具注册 | `packages/session-query/tool-session-query/src/index.ts` |
| 工具参数 | `packages/session-query/tool-session-query/src/input.ts` |
| 工具编排 | `packages/session-query/tool-session-query/src/operations.ts` |
| 授权 | `packages/session-query/tool-session-query/src/workspace-access.ts` |
| 错误边界 | `packages/session-query/tool-session-query/src/service-boundary.ts` |
| 输出呈现 | `packages/session-query/tool-session-query/src/presentation.ts` |
| 子系统文档 | `docs/subsystems/session-query.md` |
| 工具目录 | `docs/tool-catalog.md#deepseek-aidsh-tool-session-query` |