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

@@ -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')