Merge remote-tracking branch 'origin/master' into codex/basic-session-search

Conflict resolutions:

- `session.list`: master's projection columns fold into the PR's cancellable,
  batched `listVisibleSessionSummaries`, which `session.search` shares as its
  visibility baseline; master's goal helpers stay beside it.
- Client sessions face: master narrowed `ctx.sessions` to `ISessions`, so the
  search verb and its protocol-constant bound are declared there and the
  test-runtime double implements them (recorded, empty page unless a scenario
  stubs hits).
- `WorkspaceBrowser`: master's per-row Rename wiring rides the PR's search
  results view; the tree keeps the PR's query-free derivations.
- `dsh web` bin: the PR's shutdown-handlers-before-readiness order with
  master's boot-time LAN address snapshot.
- `session-query-sqlite`: master's `SCHEMA_VERSION` 7 stands; the PR's bump
  carried no schema change.
- Specs: master wraps assistant/steering message payloads and requires an
  `application/json` carrier request, so the search fixtures and tests follow.
- Web aria goldens keep master's recording plus the PR's search placeholder;
  the navigation-panes inventory keeps master's terminal-card golden next to
  the PR's search-results golden.
This commit is contained in:
Hypatia May
2026-07-30 09:40:38 +08:00
1904 changed files with 80499 additions and 17610 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/session-query/README.md
README.md: 38edbf6b0303e3d5a7bd0dc1d180cd127c60f9dc
README.zh.md: 448fae8470e11ebd5a3f7e9d30e1eb82ab995e7b
README.zh.md: f50b93076a55e7530420400089d48672d3ea6b40

View File

@@ -1,13 +1,13 @@
# session-query/:会话取回功能家族
# session-query/:会话检索能力家族
[English](README.md) | 中文
针对实时和持久会话日志提供可信的精确读取、关系跟踪、与提供方无关的语义过滤和 SQLite 全文搜索。
| 包 | 职责 | ctx 键 |
| 包(package) | 职责 | ctx 键 |
|---|---|---|
| [`session-query/`](session-query/README.md) | 组合式服务契约:提供具体的逻辑语料库读取、跟踪和语义过滤,以及抽象全文方法 | `ctx.sessionQuery` |
| [`session-query-sqlite/`](session-query-sqlite/README.md) | 具体服务后端:使用 SQLite FTS5 持久基库和实时覆盖层 | `ctx.sessionQuery` |
| [`tool-session-query/`](tool-session-query/README.md) | 工作区授权的面向模型搜索、血缘、关系和精确事件工具 | 无 |
查询服务与压缩无关:它读取规范血缘、接口操作、已记录来源信息和语义事件文本,但不参与压缩策略或执行。一个抽象服务组合全部查询操作;一个具体后端负责全文生命周期,无需提供方注册表或协调器;消费方将过大的纯文本结果交给通用执行后 spill 策略。
查询服务与压缩(compaction)无关:它读取规范血缘、接口操作、已记录来源信息和语义事件文本,但不参与压缩策略或执行。一个抽象服务组合全部查询操作;一个具体后端负责全文生命周期,无需提供方注册表或协调器;消费方将过大的纯文本结果交给通用执行后 spill 策略。

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-query-sqlite/README.md
README.md: 4bf4d979f2d2954cd6280c80bf7f5988d8121fd1
README.zh.md: afa45ad364a92e0268cf40c90dd61b163be0e18f
README.zh.md: 6eb1f3bca034a974128eb33381b5d3c383b55d12

View File

@@ -2,31 +2,31 @@
[English](README.md) | 中文
具体 `ctx.sessionQuery` 后端。`SessionQuerySqlite` 从接口包继承精确读取、跟踪和与提供方无关的过滤,并使用 SQLite FTS5 实现其两个全文方法。搜索使用实时优先的逻辑会话语料库,并按其匹配最强的事件对跨会话结果分组。
具体 `ctx.sessionQuery` 后端。`SessionQuerySqlite` 从接口包(package)继承精确读取、跟踪和提供方无关的过滤,并使用 SQLite FTS5 实现其两个全文方法。搜索使用实时优先的逻辑会话语料库,并按每个会话中匹配度最高的事件对跨会话结果分组。
## 搜索契约
`searchSessions(request, exec?)` 返回跨语料库的 `SessionSearchHit` 分页结果;`searchEvents(request, exec?)` 返回单个会话内的 `SessionEventSearchHit` 分页结果。查询不得省略,会被修剪并将空白规范化为字面短语。引号、`OR`、`NEAR` 和 `*` 等 FTS5 语法被视为数据,而非可执行 MATCH 语法。元数据过滤器是在排名前应用的参数化 SQL 谓词。为使 SQLite FTS5 MATCH 保持在受支持的外层谓词上下文中,跨会话请求最多可编译 14 个组合会话与事件过滤谓词;会话内请求最多可编译 13 个过滤谓词,因为固定目标会话谓词占用一个槽位。每个范围端点编译为一个谓词。请求超过任一谓词预算,或超过 SQLite 可移植的 32,766 总绑定上限(包括固定查询和分页值)时,会在准备语句前以 `SESSION_QUERY_INVALID_FILTER` 失败。
`searchSessions(request, exec?)` 返回跨语料库的 `SessionSearchHit` 分页结果;`searchEvents(request, exec?)` 返回单个会话内的 `SessionEventSearchHit` 分页结果。查询不得省略,首尾空白会被移除,内部空白会被规范化,并按字面短语处理。引号、`OR`、`NEAR` 和 `*` 等 FTS5 语法被视为数据,而非可执行 MATCH 语法。元数据过滤器是在排名前应用的参数化 SQL 谓词。为使 SQLite FTS5 MATCH 保持在受支持的外层谓词上下文中,跨会话请求最多可编译 14 个组合会话与事件过滤谓词;会话内请求最多可编译 13 个过滤谓词,因为固定目标会话谓词占用一个槽位。每个范围端点编译为一个谓词。请求超过任一谓词预算,或超过 SQLite 可移植的 32,766 总绑定上限(包括固定查询和分页值)时,会在准备语句前以 `SESSION_QUERY_INVALID_FILTER` 失败。
持久表和 TEMP 表之间的相关性可比:先按实际 FTS5 高亮匹配 span 数降序,再按已存储文档码点长度升序。事件时间、适用时的会话 id 和 seq 打破其余平局。跨会话结果将所选事件公开为 `bestMatch`;两种范围都从 FTS5 高亮位置派生空白规范化的纯文本,并按 Unicode 码点限制长度。游标是不透明的品牌化值,绑定到规范化请求和服务实例,并在相关世代变更时失败。会话内游标可在不相关会话变更后延续使用;跨会话游标则不能。
持久表和 TEMP 表之间的相关性排名可直接比较:先按实际 FTS5 高亮匹配 span 数降序,再按已存储文档码点长度升序。事件时间、适用时的会话 id 和 seq 打破其余平局。跨会话结果将所选事件公开为 `bestMatch`;两种范围都从 FTS5 高亮位置派生空白规范化的纯文本,并按 Unicode 码点限制长度。游标是带品牌类型的不透明值,绑定到规范化请求和服务实例,并在相关世代变更时失败。会话内游标可在不相关会话变更后延续使用;跨会话游标则不能。
默认可搜索全部三种接口(`current`、`shadowed` 和 `log-only`)。传入接口过滤器可缩小范围。
## 来源与索引生命周期
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,以非变更方式只检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间附加的 owner 无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该实时 owner 脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
该服务需要 `ctx.sessions`,并动态观察可选的 `ctx.sessionPersistence`。一个串行化状态机比较来源限定的轻量持久化快照修订,仅以不修改日志的方式检查新日志或已更改日志,提取共享语义文档,以事务方式对账变更,然后运行查询。会话查询绝不会调用持久化后端会修复崩溃的 `load()`;检查期间接入的活动所有者无法修改其日志,稳定观察重试使结果优先使用实时来源。TEMP 实时行仍会记录持久化可用性,而持久基库会在该活动所有者脱离后刷新。重复查询和未变的同存储重新打开不会执行完整持久化日志检查;切换存储,或观察到新增、已更改、已删除或经外部 load 修复的来源时,会在下次稳定观察时对账。来源或事务失败不会提交任何内容,下一次搜索会重试。
`openAt: startup` 是默认值:服务激活会导入 `node:sqlite` 并打开句柄;如果索引无效,则会在服务发布前失败。`openAt: first-search` 会将服务以 ACTIVE 状态发布,同时不导入 SQLite 模块也不打开句柄;首批并发搜索共享同一个就绪 promise,在任何搜索前处置服务时也不会导入模块或打开句柄。此模式通过把 SQLite 的实验性警告推迟到首次实际搜索,支持需要干净 Node 22 启动输出的组合;它不会抑制届时的警告。无效数据库同样会使首次搜索失败,而不是服务激活失败。
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时 owner 消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
持久化 FTS 行位于专用派生数据库中。连接本地 TEMP 表保存实时行,这些行会遮蔽同一会话的持久化基库,并在实时所有者消失后使其重新可见。卸载持久化会隐藏持久行,但不会丢弃缓存;重新挂载会对账缓存。关闭或重新打开数据库会删除全部实时覆盖层,但保留持久行。
该数据库可丢弃,但 reset 受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态归连接所有。
该数据库虽可丢弃重建,但 reset 操作受到保护:每个已识别 schema 版本都会在修改 journal mode 前拒绝未知用户表;只有包含派生表的已识别不兼容 schema 才会原地重建。不相关数据库或规范数据库将被拒绝。绝不能将 `path` 指向 session-persistence 数据库。在具有 POSIX mode 的文件系统上,缺失的目录和数据库会以仅所有者可访问的方式创建(进程 umask 前为 `0700` 和 `0600`),SQLite sidecar 继承数据库 mode;现有 mode 保持不变。每个派生索引路径在一个进程中只能由一个服务拥有;不支持外部写入者或第二个进程,因为世代和 TEMP 遮蔽状态由连接持有。
## 配置
| 键 | 默认值 | 契约 |
|---|---:|---|
| `path` | required | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 |
| `path` | 必填 | 专用派生索引 SQLite 路径;支持 `:memory:`。在 POSIX 文件系统上,缺失的文件系统路径会以仅所有者可访问的方式创建。 |
| `openAt` | `startup` | `startup` 会在服务激活完成前打开;`first-search` 把 SQLite 模块与句柄推迟到搜索时再加载和打开。 |
| `journalMode` | `wal` | `wal`、`delete`、`truncate` 或 `persist`。 |
| `defaultLimit` | `20` | 请求省略 `limit` 时的分页大小;最多为 `Number.MAX_SAFE_INTEGER - 1`。 |
@@ -35,23 +35,23 @@
| `readWindowMax` | `50` | `before` 或 `after` 的最大原始事件数,用于继承的 `readEvent()`。 |
| `persistedInspectConcurrency` | `4` | 继承批量读取的最大并发持久化日志检查数;必须是正安全整数。 |
## Tokenizer 与限制
## 分词器与限制
该索引使用 FTS5 `unicode61`。在实现实验中,它支持双字符查询 `AI`,产生的索引比 trigram 备选方案小约 2.1 倍。取舍是 token/短语召回而非任意子字符串召回:`AI` 不匹配 token `BRAID`。需要执行字面的空白弹性子字符串扫描时,使用 `ctx.sessionQuery.filterEvents()` 并传入 `text` 子句。查询会拒绝 NUL;文档中的保留高亮标记和 NUL 会在索引前被规范化,使展示标记无法与源文本冲突。
中止信号会停止已排队工作,并原样流经快照列表和非变更检查。来源工作一旦开始,串行化状态机会自行等待该后端 promise,即使后端忽略取消,之后也会在启动任何其他列表、检查、对账或查询工作前检查信号。因此,调用方只会在已启动后端工作完全停稳后观察到取消,而后续搜索在该清理尚未完成时无法进入 serializer。Node 的同步 `DatabaseSync` API 无法中断已在 JavaScript 线程上执行的元数据或 MATCH 语句;系统会在这些不可抢占调用前后立即检查信号。
中止信号会停止已排队工作,并原样流经快照枚举和非修改式检查。来源工作一旦开始,串行化状态机会自行等待该后端 promise,即使后端忽略取消,之后也会在启动任何进一步的枚举、检查、对账或查询工作前检查信号。因此,调用方只会在已启动后端工作完全停稳后观察到取消,而后续搜索在该清理尚未完成时无法进入 serializer。Node 的同步 `DatabaseSync` API 无法中断已在 JavaScript 线程上执行的元数据或 MATCH 语句;系统会在这些不可抢占调用前后立即检查信号。
## 模型体验
无。该可信搜索后端只向调用方返回命中,不注册面向模型的提示词、schema、工具或消息。
#### KV 缓存影响
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与待完成工作
## 已知限制与暂缓事项
- **无调用方授权**:这是上下文范围内的可信服务;模型工具或 UI 必须强制执行自己的访问策略。
- **同步查询执行**:`DatabaseSync` 在 MATCH 执行期间会阻塞 JavaScript 线程,且无法中断已运行的语句。
- **Token 召回,而非任意子字符串**:`unicode61` tokenizer 不会匹配更大 token 中的子字符串;对字面扫描使用 `filterEvents()`。
- **单 owner 派生索引**:每个索引路径必须由一个进程中的一个服务拥有;不支持外部写入者和多进程共享。
- **单一所有者的派生索引**:每个索引路径必须仅归一个进程中的一个服务所有;不支持外部写入者和多进程共享。

View File

@@ -5,7 +5,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 6
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 7
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/**
* Keyless real-Loader-path smoke for the combined SQLite session-query service.
*
@@ -48,7 +49,9 @@ describe('dsh-session-query-sqlite real Loader path', () => {
type: 'user/message',
seq: 0,
time: 10,
data: { content: [{ type: 'text', text: 'real Loader needle' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'real Loader needle' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}])

View File

@@ -1,3 +1,4 @@
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
@@ -44,7 +45,9 @@ function messageEvents(text: string, time = 1): SessionEvent[] {
type: 'user/message',
seq: 0,
time,
data: { content: [{ type: 'text', text }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}]
}
@@ -146,6 +149,11 @@ class TestPersistence extends SessionPersistence {
return structuredClone(entry)
}
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id, signal)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
async list(): Promise<SessionHeader[]> {
TestPersistence.listStarted?.()
await TestPersistence.listGate
@@ -276,7 +284,9 @@ describe('SQLite session search', () => {
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
@@ -297,11 +307,13 @@ describe('SQLite session search', () => {
{
turn: 1,
step: 1,
content: [
{ type: 'reasoning', text: 'private-chain-marker' },
{ type: 'text', text: 'visible-answer-marker' },
],
provenance: { provider: 'mock', model: 'mock' },
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: 'private-chain-marker' },
{ type: 'text', text: 'visible-answer-marker' },
],
source: { provider: 'mock', model: 'mock' },
}),
},
{ surfaceOp: 'append' },
)
@@ -321,9 +333,13 @@ describe('SQLite session search', () => {
const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 })
const parent = SessionId('parent')
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 0, time: 10, data: createUserMessage({
content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } },
{ type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'user/message', seq: 2, time: 12, data: createUserMessage({
content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
}), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } },
]
ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
@@ -542,7 +558,9 @@ describe('SQLite session search', () => {
cursor: eventPage.nextCursor,
})).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR'))
target.append('user/message', { content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
target.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
await expect(ctx.sessionQuery.searchEvents({
sessionId: target.id,
query: 'needle',
@@ -718,7 +736,9 @@ describe('SQLite reconciliation and source lifecycle', () => {
await expect(ctx.sessionQuery.searchSessions({ query: 'durable' }))
.resolves.toMatchObject({ items: [{ header: durable, live: false, persisted: true }] })
const live = ctx.sessions.prepare(shared.id, { meta: { createdAt: 10, cwd: '/work' } })
live.append('user/message', { content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
live.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const detach = ctx.sessions.enter(live)
ctx.sessions.announce(live)
@@ -1151,7 +1171,9 @@ describe('SQLite reconciliation and source lifecycle', () => {
await ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'base' })
const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db
db.exec('PRAGMA query_only = ON')
live.append('user/message', { content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
live.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
db.exec('PRAGMA query_only = OFF')

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md
README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063
README.zh.md: cc79a6f48b4c997a4e940f99aaab169291e2b900
README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f

View File

@@ -10,47 +10,47 @@
- `readSession(sessionId)` 在执行与恢复相同的核心回放验证后,返回一份完整、脱离存储的原始日志;它绝不会将该会话放入实时存储。
- `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。
- `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。
- `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消传递给持久化列表和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。
- `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 接口 fold。
- `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整 folded 当前接口。实时会话优先于持久化;压缩只会在其替换追加之前或之后被观察,绝不会出现合成混合。
- `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消信号传递给持久化列表查询和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。
- `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 表层 fold。
- `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整折叠后当前表层。实时会话优先于持久化;压缩(compaction)只会在其替换追加之前或之后被观察,绝不会出现合成混合。
- `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。
- `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接到向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。
- `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。
- `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。
持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前列表,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪取消传递到持久化列表;事件跟踪和事件读取取消传递到持久化列表和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。预先中止的已知实时标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。
持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。
## 过滤与提取
`SessionResultFilter` 覆盖 id、可空 cwd、创建时间范围、可空父级和来源可用性。`SessionEventResultFilter` 覆盖 seq/时间范围、事件类型、接口和语义文本。过滤器数组使用 AND;同一列表子句内的值使用 OR。空列表值不匹配任何内容,范围包含端点,而格式错误的范围或封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。
`SessionResultFilter` 覆盖 id、可空 cwd、创建时间范围、可空父级和来源可用性。`SessionEventResultFilter` 覆盖 seq/时间范围、事件类型、表层和语义文本。过滤器数组使用 AND;同一列表子句内的值使用 OR。空列表值不匹配任何内容,范围包含端点,而格式错误的范围或封闭联合值以 `SESSION_QUERY_INVALID_FILTER` 失败。
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每个空白运行匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()` 和 `buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;推理(reasoning)块、结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
文本子句刻意与 FTS 提供方无关:调用方文本会被转义为不区分大小写的 Unicode 正则表达式,每段连续空白匹配一个或多个空白字符。它是字面语义文本扫描,而非全文查询。`extractSessionEventText()` 和 `buildSessionEventSearchDocuments()` 定义共享的第一方文档投影;推理(reasoning)块、结构边界、流分片、请求 header 和未知声明合并变体不产生文档。
## 全文方法
`SessionQueryService.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是自有的品牌化 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下公开 snippet。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到 payload 观察。搜索请求只接受元数据事件过滤器,因为字面文本过滤使用上文所述扫描路径。
`SessionQueryService.searchSessions(request, exec?)` 按匹配最强的事件对逻辑语料库分组;`searchEvents(request, exec?)` 搜索一个逻辑会话。这两个是服务仅有的抽象方法。两者都返回分页结果,其延续信息是由服务持有的带品牌 `SessionSearchCursor`;接受可选取消,并在不使用提供方专用数值分数的情况下提供摘录。事件搜索分页结果还携带来自与命中相同索引世代的克隆目标 header,使授权消费方可将策略绑定到此次载荷观察。搜索请求只接受事件元数据过滤器,因为字面文本过滤使用上文所述扫描路径。
该包没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。
该包(package)没有提供方协调器、回退实现或独立具体插件。具体服务后端继承已实现的读取、过滤和跟踪,同时负责全文观察、对账、排名、游标世代和查询执行;第一个实现是 [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md)。
`SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的接口、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。
`SessionQueryError.code` 是一个封闭联合,覆盖请求验证、缺失目标、格式错误的表层、来源冲突、持久化/索引失败、取消,以及无效或陈旧游标;精确字面值在 [`src/config.ts`](src/config.ts) 中定义。
`listEvents()`、`readSurface()` 和 `traceEvent()` 执行同一个单遍 `dsh-session` 接口 fold。只有当事件 seq 从零开始且连续、接口标记符合事件类型资格、来源数组非空且无重复、引用指向较早事件,且每个位置替换都命名并引用它移除的每个接口节点时,加载的日志才有效;任何违规都以 `SESSION_QUERY_INVALID_SURFACE` 失败。
`listEvents()`、`readSurface()` 和 `traceEvent()` 执行同一个单遍 `dsh-session` 表层 fold。只有当事件 seq 从零开始且连续、表层标记符合事件类型的适用性要求、来源数组非空且无重复、引用指向较早事件,且每个位置替换都命名并引用它移除的每个表层节点时,加载的日志才有效;任何违规都以 `SESSION_QUERY_INVALID_SURFACE` 失败。
## 配置
| 键 | 默认值 | 契约 |
|---|---:|---|
| `readWindowMax` | `50` | `before` 或 `after` 的最大原始事件数。 |
| `persistedInspectConcurrency` | `4` | 一次批量读取中的最大并发持久化日志检查数;必须是正安全整数。 |
| `persistedInspectConcurrency` | `4` | 一次批量读取中的最大并发持久化日志检查数;必须是正的安全整数。 |
## 模型体验
无。该可信查询服务只向调用方返回克隆会话记录,不注册面向模型的提示词、schema、工具或消息。
#### KV 缓存影响
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与待完成工作
## 已知限制与暂缓事项
- **无调用方授权**:这是上下文范围内的可信基础设施;未来的模型工具或 UI 必须限制调用方可检查的会话。
- **无注册表或面向模型工具**:尚未提供提取器和搜索提供方注册表、递归事件来源遍历以及面向模型的工具。[跟踪决策](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) 负责关系语义;SQLite 归属和 tokenizer 决策位于[已实现搜索记录](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md)。

View File

@@ -39,7 +39,7 @@ export class SessionQueryError extends HarnessError {
declare readonly code: SessionQueryErrorCode
// The base stores the value; this signature narrows its open string code.
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
// oxlint-disable-next-line typescript/no-useless-constructor
constructor(message: string, code: SessionQueryErrorCode, options?: ErrorOptions) {
super(message, code, options)
}

View File

@@ -13,14 +13,15 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
export function extractSessionEventText(event: SessionEvent): string {
switch (event.type) {
case 'user/message':
return contentText(event.data.content)
case 'assistant/message':
case 'steering/message':
return contentText(event.data.content)
return contentText(event.data.message.content)
case 'tool/call':
return joinText([event.data.name, event.data.arguments])
case 'tool/result':
return joinText([
contentText(event.data.content),
contentText(event.data.message.content),
event.data.error?.name ?? '',
event.data.error?.code ?? '',
])

View File

@@ -5,7 +5,7 @@
*/
import { Context, Service } from 'cordis'
import { Session, type SessionId } from '@deepseek-ai/dsh-session'
import { Session, snapshotSessionEvent, type SessionId } from '@deepseek-ai/dsh-session'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
import type {
@@ -146,7 +146,7 @@ export abstract class SessionQueryService extends Service {
new Session(sessionId, loaded.events, loaded.header)
return {
session: structuredClone(loaded.header),
events: loaded.events.map(event => structuredClone(event)),
events: loaded.events.map(snapshotSessionEvent),
}
}
@@ -330,10 +330,15 @@ export abstract class SessionQueryService extends Service {
}
const startSeq = Math.max(0, seq - before)
const endSeq = Math.min(loaded.events.length - 1, seq + after)
const targetSnapshot = snapshotSessionEvent(target)
const events = loaded.events.slice(startSeq, endSeq + 1)
.map(event => event === target
? targetSnapshot
: snapshotSessionEvent(event))
return {
session: loaded.header,
target,
events: loaded.events.slice(startSeq, endSeq + 1),
session: structuredClone(loaded.header),
target: targetSnapshot,
events,
startSeq,
endSeq,
}

View File

@@ -1,6 +1,6 @@
/** One-shot session-lineage and event-relationship tracing helpers. */
import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import { foldSurface, isSurfaceEvent, snapshotSessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from './config.ts'
import type {
@@ -51,7 +51,7 @@ export function currentSurfaceEvents(
'SESSION_QUERY_INVALID_SURFACE',
)
}
return structuredClone(event)
return snapshotSessionEvent(event)
})
}
@@ -91,7 +91,7 @@ export function traceEvent(
}
// The target check above proves the parallel record exists at this index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const targetRecord = analysis.records[seq]!
const replacedBy = analysis.replacedBy.get(seq)
return {
@@ -225,7 +225,7 @@ function buildDescendants(
const stack = [{ sessionId, descendants }]
while (stack.length > 0) {
// The length guard proves a frame exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const frame = stack.pop()!
const nodes: SessionLineageNode[] = []
for (const child of childrenByParent.get(frame.sessionId) ?? []) {
@@ -235,7 +235,7 @@ function buildDescendants(
}
for (let index = nodes.length - 1; index >= 0; index -= 1) {
// The loop bounds prove this indexed node exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// oxlint-disable-next-line typescript/no-non-null-assertion
const node = nodes[index]!
stack.push({ sessionId: node.session.header.id, descendants: node.descendants })
}

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
buildSessionEventRecords,
@@ -42,13 +45,58 @@ describe('session-query semantic extraction', () => {
{ type: 'future-content', payload: 'hidden' } as never,
]
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({
content: messageContent, source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/message', seq: 1, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: messageContent,
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
{ type: 'user/message', seq: 2, time: 3, data: createUserMessage({
content: messageContent, source: { kind: 'plugin', plugin: 'test' },
}), surfaceOp: 'append' },
{ type: 'steering/message', seq: 3, time: 4, data: {
turn: 1,
message: createUserMessage({
content: messageContent,
source: { kind: 'user' },
}),
}, surfaceOp: 'append' },
{ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } },
{ type: 'tool/result', seq: 5, time: 6, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' },
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' },
{
type: 'tool/result',
seq: 5,
time: 6,
data: {
turn: 1,
step: 1,
message: createToolResultMessage({
callId,
content: [{ type: 'text', text: 'failed' }],
isError: true,
}),
error: { name: 'Oops', code: 'E_OOPS' },
},
surfaceOp: 'append',
},
{
type: 'tool/result',
seq: 6,
time: 7,
data: {
turn: 1,
step: 1,
message: createToolResultMessage({ callId, content: [], isError: false }),
},
surfaceOp: 'append',
},
{ type: 'todo/write', seq: 7, time: 8, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } },
]
@@ -62,8 +110,11 @@ describe('session-query semantic extraction', () => {
data: {
turn: 1,
step: 1,
content: [{ type: 'reasoning', text: 'private thought' }],
provenance: { provider: 'mock', model: 'mock' },
message: createMessage({
role: 'assistant',
content: [{ type: 'reasoning', text: 'private thought' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
},
surfaceOp: 'append',
})).toBe('')
@@ -102,9 +153,21 @@ describe('session-query semantic extraction', () => {
describe('session-query document and filter helpers', () => {
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'user/message', seq: 0, time: 10, data: createUserMessage({
content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'assistant/message', seq: 2, time: 12, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
]
@@ -175,7 +238,17 @@ describe('session-query document and filter helpers', () => {
type: 'assistant/message',
seq: 0,
time: 1,
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }], provenance: { provider: 'mock', model: 'mock' } },
data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'bad' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: { op: 'replace', start: 9, end: 9 },
}]
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
@@ -211,8 +284,12 @@ describe('session-query document and filter helpers', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(TestSessionQueryService)
const session = ctx.sessions.create(id)
session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'other' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }]))
.resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }])
})

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
@@ -20,7 +21,9 @@ function eventLog(text = 'hello'): SessionEvent[] {
type: 'user/message',
seq: 0,
time: 10,
data: { content: [{ type: 'text', text }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}]
}
@@ -93,6 +96,11 @@ class TestPersistence extends SessionPersistence {
return Promise.resolve(result)
}
async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id, signal)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
list(signal?: AbortSignal): Promise<SessionHeader[]> {
TestPersistence.listCalls += 1
TestPersistence.listSignals.push(signal)
@@ -126,7 +134,7 @@ function expectCode(code: SessionQueryErrorCode): Error {
function rejectUnknown<T>(reason: unknown): Promise<T> {
return new Promise<T>((_resolve, reject) => {
// Exercise containment for an implementation that violates the Error rejection convention.
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
// oxlint-disable-next-line typescript/prefer-promise-reject-errors
reject(reason)
})
}
@@ -855,7 +863,9 @@ describe('session-query exact reads', () => {
const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'live' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
const persistence = await ctx.plugin(TestPersistence)
@@ -887,7 +897,9 @@ describe('session-query exact reads', () => {
session.append('step/start', { turn: 1, step: 1 })
const first = session.append(
'user/message',
{ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append('assistant/chunk', {
@@ -897,7 +909,17 @@ describe('session-query exact reads', () => {
})
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
@@ -910,7 +932,9 @@ describe('session-query exact reads', () => {
const session = ctx.sessions.create(SessionId('surface-snapshot'), { meta: { cwd: '/work' } })
const first = session.append(
'user/message',
{ content: [{ type: 'text', text: 'old' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'old' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append('assistant/chunk', {
@@ -920,22 +944,38 @@ describe('session-query exact reads', () => {
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
createUserMessage({
content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
}),
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
const retained = session.append(
'user/message',
{ content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append(
'user/message',
{ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
createUserMessage({
content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' },
}),
{ surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] },
)
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'latest answer' }] },
{
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'latest answer' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: 'append' },
)
@@ -947,7 +987,9 @@ describe('session-query exact reads', () => {
[5, 'assistant/message'],
])
if (snapshot.events[0]?.type !== 'user/message') throw new Error('expected current user message')
snapshot.events[0].data.content = []
expect(() => {
(snapshot.events[0]!.data as { content: unknown[] }).content = []
}).toThrow()
Object.assign(snapshot.session, { cwd: '/mutated' })
expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1)
@@ -970,7 +1012,9 @@ describe('session-query exact reads', () => {
for (const text of ['one', 'two', 'three']) {
session.append(
'user/message',
{ content: [{ type: 'text', text }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
}
@@ -980,7 +1024,9 @@ describe('session-query exact reads', () => {
expect(result.session).toEqual(session.header)
Object.assign(result.session, { createdAt: -1 })
if (result.events[0]?.type !== 'user/message') throw new Error('expected user message')
result.events[0].data.content = []
expect(() => {
(result.events[0]!.data as { content: unknown[] }).content = []
}).toThrow()
expect(session.header.createdAt).not.toBe(-1)
expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1)
@@ -1007,7 +1053,9 @@ describe('session-query exact reads', () => {
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'live' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
const persistence = await ctx.plugin(TestPersistence)
@@ -1045,7 +1093,9 @@ describe('session-query exact reads', () => {
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'available' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
await ctx.plugin(TestPersistence)
@@ -1097,7 +1147,9 @@ describe('session-query exact reads', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' },
}),
}],
}])
const persistence = await ctx.plugin(TestPersistence)

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
@@ -22,7 +23,9 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent {
type: 'user/message',
seq,
time: seq + 1,
data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' },
}),
surfaceOp: 'append',
...sources === undefined ? {} : { sourceEventSeqs: sources },
}
@@ -73,6 +76,11 @@ class TracePersistence extends SessionPersistence {
return Promise.resolve(structuredClone(entry))
}
async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const whole = await this.inspect(id)
return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
}
list(): Promise<SessionHeader[]> {
TracePersistence.listCalls += 1
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
@@ -107,24 +115,48 @@ function appendTraceEvents(session: Session): void {
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append', sourceEventSeqs: [2] },
)
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary one' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
)
session.append(
'user/message',
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
createUserMessage({
content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
}),
{ surfaceOp: 'append' },
)
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
{
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary two' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] },
)
}
@@ -319,7 +351,9 @@ describe('session event tracing', () => {
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
createUserMessage({
content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' },
}),
{ surfaceOp: 'append' },
)
TracePersistence.listFailure = new Error('list unavailable')
@@ -352,7 +386,17 @@ describe('session event tracing', () => {
type: 'assistant/message',
seq: 1,
time: 2,
data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } },
data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: { op: 'replace', start: 9, end: 9 },
sourceEventSeqs: [],
}]

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
# pnpm run verify-translation-pairing --write packages/session-query/tool-session-query/README.md
README.md: d973daf1124c4be05f7335b18661d431d45be39f
README.zh.md: 94cbd4e36c7146c7759b49421cf651abb327b5f4
README.zh.md: b27d79a905a029d3750f24573e3c32785a314015

View File

@@ -2,20 +2,20 @@
[English](README.md) | 中文
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `maxSearchResults` | `100` | 在内部提供方分页中收集的最大已授权非自身命中数 |
| `searchTimeoutMs` | `30000` | 连接到两个全文搜索工具的协作式 deadline |
| `searchTimeoutMs` | `30000` | 附加到两个全文搜索工具的协作式截止时间 |
调用方只能来自 `ToolExecution.exec.agent`。跨会话访问要求目标和调用方会话的 `cwd` 值严格相等;没有 `cwd` 的调用方只能检查自己。搜索绝不公开提供方游标、偏移、分页大小或模型可控上限。由于一次搜索会在内部消费与世代绑定的提供方游标,两个搜索工具都与同级工具调用排他执行;三个精确跟踪/读取工具选择并行执行。每个精确执行器都将未更改的执行信号传递给授权和服务跟踪/读取,因此取消会等待协作式持久化清理,并保留信号的精确原因。工具边界上的时间戳要求显式 `Z` 或数字偏移,并转换为包含端点的 epoch 毫秒过滤器。
`session_search` 始终省略调用方会话。请求的父 id 会被去重,并在 FTS 前根据调用方工作区权限检查;只有已授权 id 会到达提供方,而缺失猜测和跨工作区猜测的行为完全相同,root 标记仍独立使用 OR。当前会话中的 `session_event_search` 会在调用它的步骤之前立即停止,因此当前 assistant 输出和已记录工具调用无法匹配自身。直接目标在跟踪、事件或标题读取前完成授权。血缘输出会用不含隐藏会话 id 的标记替换未授权祖先和后代边界。
每个可信 `ctx.sessionQuery` 调用都会经过一个模型边界净化器。首先检查调用方取消,并精确保留。可用语料库和提供方诊断(包括可安全检查的嵌套原因)会尽力记录到内部日志;不可打印的失败使用固定日志占位符。诊断格式化和错误分类各自独立受保护,因此不可打印的原因无法逃逸,也无法阻止已安全分类的外层错误;不安全的分类或日志记录则回退到固定 `SESSION_QUERY_TOOL_FAILED` 代码和消息。本地参数验证和授权错误保留精确的工具自有消息。
每个可信 `ctx.sessionQuery` 调用都会经过一个模型边界净化器。首先检查调用方取消,并精确保留。可获取的语料库诊断信息和提供方诊断信息(包括可安全检查的嵌套原因)会尽力记录到内部日志;不可打印的失败使用固定日志占位符。诊断格式化和错误分类各自独立受保护,因此不可打印的原因无法逃逸,也无法阻止已安全分类的外层错误;不安全的分类或日志记录则回退到固定 `SESSION_QUERY_TOOL_FAILED` 代码和消息。本地参数验证和授权错误保留精确的工具自有消息。
该包刻意不执行字节或字符截断,也不导入 spill 后端。需要限制内联输出的部署应挂载 `@deepseek-ai/dsh-spill-policy`,它可在执行后替换已渲染文本,同时保留完整结果。
@@ -23,7 +23,7 @@
### 系统提示词
#### 模型所见
#### 模型看到的内容
模型会收到一个固定的既往历史指引章节。
@@ -37,13 +37,13 @@ Use session_search to find relevant work from prior sessions, or session_event_s
插件挂载期间,每次请求都存在一个固定精简章节。
#### KV 缓存影响
#### KV Cache 影响
插件和指引文本不变时,前缀稳定。
### 工具 schema
#### 模型所见
#### 模型看到的内容
模型会看到生成的 [`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query)。搜索过滤器会增加固定 schema token,而游标、工作区路径、输出分页和模型可控结果上限仍不存在。
@@ -51,26 +51,26 @@ Use session_search to find relevant work from prior sessions, or session_event_s
可见期间,每次请求都会发送 5 个固定只读 schema。
#### KV 缓存影响
#### KV Cache 影响
工具可见性和定义不变时,前缀稳定。
### 工具结果
#### 模型所见
#### 模型看到的内容
每次成功调用都会发出一个纯文本块。搜索结果包含标题和最佳匹配摘要;跟踪包含全部已授权关系;事件读取包含未缩写的目标 JSON。通用 spill 策略可以将过大的内联文本替换为预览、不透明定位信息和取回指引。
每次成功调用都会发出一个纯文本块。搜索结果包含标题和最佳匹配摘录;跟踪包含全部已授权关系;事件读取包含未经删节的目标 JSON。通用 spill 策略可以将过大的内联文本替换为预览、不透明定位信息和取回指引。
#### Token 影响
结果取决于数据,并保留在已记录工具历史中直到压缩;`maxSearchResults` 限制搜索命中数。
结果取决于数据,并保留在已记录工具历史中直到压缩(compaction);`maxSearchResults` 限制搜索命中数。
#### KV 缓存影响
#### KV Cache 影响
仅追加的结果文本位于可重用请求前缀之后,不会使较早的缓存条目失效。
## 已知限制与待完成工作
## 已知限制与暂缓事项
- 搜索最多返回部署上限,匹配更多时会请模型缩小查询;不提供延续 token。
- 工作区身份使用保守的字符串精确 `cwd` 相等性,因此符号链接等价的路径不共享权限。
- 未挂载通用 spill 策略的自定义组合会在内联位置接受完整跟踪和事件 payload。
- 未挂载通用 spill 策略的自定义组合会以内联方式接收完整跟踪和事件载荷。

View File

@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
@@ -54,10 +54,10 @@ describe('tool-session-query with the real SQLite provider', () => {
type: 'user/message',
seq: 0,
time: 2,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'persisted integration needle' }],
source: { kind: 'user' },
},
}),
surfaceOp: 'append',
}])
@@ -67,7 +67,9 @@ describe('tool-session-query with the real SQLite provider', () => {
caller.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
caller.append(
'user/message',
{ content: [{ type: 'text', text: 'live integration needle' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'live integration needle' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
caller.append('step/start', { turn: 1, step: 1 })
@@ -123,40 +125,40 @@ describe('tool-session-query with the real SQLite provider', () => {
type: 'user/message',
seq: 0,
time: base + 123,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'fractional integration needle' }],
source: { kind: 'user' },
},
}),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 1,
time: base + 124,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'fractional integration needle' }],
source: { kind: 'user' },
},
}),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 2,
time: -124,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
source: { kind: 'user' },
},
}),
surfaceOp: 'append',
},
{
type: 'user/message',
seq: 3,
time: -123,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'pre-epoch fractional needle' }],
source: { kind: 'user' },
},
}),
surfaceOp: 'append',
},
])

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import SessionStore, {
@@ -67,7 +67,9 @@ function openStep(session: Session, text = 'prior needle'): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append(
'user/message',
{ content: [{ type: 'text', text }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append('step/start', { turn: 1, step: 1 })
@@ -871,7 +873,9 @@ describe('workspace authority and lineage redaction', () => {
const target = createSession(mounted.ctx, `${toolName}-failure-target`, '/work')
target.append(
'user/message',
{ content: [{ type: 'text', text: 'event' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'event' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
const secret = `event missing beside hidden-${toolName}-secret`
@@ -900,7 +904,9 @@ describe('workspace authority and lineage redaction', () => {
const target = createSession(mounted.ctx, `cancelled-${toolName}`, '/work')
target.append(
'user/message',
{ content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
const controller = new AbortController()
@@ -1130,7 +1136,9 @@ describe('workspace authority and lineage redaction', () => {
const target = createSession(mounted.ctx, 'moving-target', '/work')
target.append(
'user/message',
{ content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
const movedHeader = header(target.id, '/outside')
@@ -1436,7 +1444,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
},
])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => {
const mounted = await mount()
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile unknown rejection is the scenario
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- hostile unknown rejection is the scenario
FakeQuery.sessionSearch = () => Promise.reject(failure())
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
@@ -1947,7 +1955,9 @@ describe('trace and exact read rendering', () => {
const session = createSession(mounted.ctx, 'relationships', '/work')
session.append(
'user/message',
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append(
@@ -1955,8 +1965,14 @@ describe('trace and exact read rendering', () => {
{
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
provenance: { provider: 'test', model: 'test' },
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'test', model: 'test' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
)
@@ -1971,7 +1987,9 @@ describe('trace and exact read rendering', () => {
const session = createSession(mounted.ctx, 'read', '/work')
session.append(
'user/message',
{ content: [{ type: 'text', text: 'before semantic text' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'before semantic text' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
session.append(
@@ -1979,8 +1997,14 @@ describe('trace and exact read rendering', () => {
{
turn: 1,
step: 1,
content: [{ type: 'text', text: 'target full text' }],
provenance: { provider: 'test', model: 'test' },
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'target full text' }],
source: {
kind: 'model',
...{ provider: 'test', model: 'test' },
},
}),
},
{ surfaceOp: 'append' },
)