diff --git a/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.i18n.yaml new file mode 100644 index 0000000000..270d93300e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.i18n.yaml @@ -0,0 +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 .agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.md +2026-08-13-session-content-search-opt-in.md: 2d14b2def00186c2957dc03ae712fbcb63d2c820 +2026-08-13-session-content-search-opt-in.zh.md: dbde561c82cebdbf51ad31b1cd22c908c8d0f327 diff --git a/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.md b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.md new file mode 100644 index 0000000000..2d14b2def0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.md @@ -0,0 +1,32 @@ +# Agent Note: Session content search ships opt-in through openAt never + +Status: implemented + +English | [中文](2026-08-13-session-content-search-opt-in.zh.md) + +## Problem + +The shipped bundles mounted the SQLite session-query provider with the full-text index live (`openAt: first-search`), so every default deployment carried a derived FTS index and the Web sidebar offered content search. Whether a deployment wants that index — its node:sqlite import, per-search source reconciliation, and derived storage — is a deployment choice, and the product default is to ship without it; the model-facing search tools were already opt-in and unmounted (the [not-shipped-default decision](../feature/2026-08-02-session-search-not-shipped-default.md)). + +Turning the capability off by unmounting the plugin row is not viable. `ApiProxyService` declares `sessionQuery` as a required injection, so without the provider the whole host API gateway stays unloaded and the Web GUI never boots. Session-log export traces subagent descendants through `ctx.sessionQuery.traceSession`, and a subagent fork resolves its Workspace through the same lineage trace — both would need optional-service guards plus a replacement lineage source, roughly tripling the change surface while losing exact reads everywhere. + +## Decision + +Content search is enforced off at the provider. `openAt: 'never'` is a third opening phase on `@deepseek-ai/dsh-session-query-sqlite`: `searchSessions` and `searchEvents` fail with the typed `SESSION_QUERY_SEARCH_DISABLED` code before any request normalization, node:sqlite is never imported or opened, and no source observation or reconciliation runs. Every inherited `ctx.sessionQuery` exact read, filter, and trace keeps working, so session export, fork Workspace inheritance, and title reads are unaffected. + +`SESSION_QUERY_SEARCH_DISABLED` joins the closed `SessionQueryErrorCode` taxonomy, and the `tool-session-query` service boundary maps it to the model-safe message `session search is disabled in this deployment`. + +The base bundle sets `openAt: never` on the `session-query-sqlite` row and the web bundle's restatement keeps it; enabling content search is a one-line `openAt` override (`first-search` or `startup`) in a later patch layer, typically with a durable `path`. The host `session.search` endpoint reports the provider failure through its existing error path, and the Web sidebar keeps its designed degradation: local title/workspace matching plus the content-search-unavailable notice. The CLI compat spec pins the shipped `openAt: never` rows, while the web e2e scaffold keeps content search enabled — its seeded-session scenarios navigate by content search, and those runs are the assembled coverage for the opt-in path. + +## Alternatives considered + +- **Unmount the plugin row** (`disabled: true` in the base patch): rejected — the api-gateway's required `sessionQuery` injection keeps the whole host API unloaded, and making that injection optional forces guards plus a header-walk lineage fallback in session export and fork resolution. +- **Disable at the consumers** (the host `session.search` endpoint or the sidebar): rejected — enforcement belongs to the operation that makes the decision; opt-in model tools or any other consumer would still reach the index. +- **A separate boolean beside `openAt`**: rejected — the opening phase already owns when SQLite starts; `never` extends the same axis instead of adding a second knob that can contradict it. + +## Consequences + +- Default deployments run no derived index: no node:sqlite import or experimental-SQLite startup warning, no reconciliation work, no derived database on disk. Sidebar search matches session titles and workspace names only. +- Search failures under the default are typed and stable rather than incidental, so callers distinguish a deployment choice from an index fault (`SESSION_QUERY_INDEX_FAILED`). +- Re-enabling content search is per-deployment configuration, not a code change, and restores the full FTS behavior unchanged. +- Compositions that mount the search tools without overriding `openAt` get the model-safe disabled message on every search call; enabling the tools implies enabling the index. diff --git a/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.zh.md b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.zh.md new file mode 100644 index 0000000000..dbde561c82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-13-session-content-search-opt-in.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 会话内容搜索通过 openAt never 以 opt-in 方式交付 + +Status: implemented + +[English](2026-08-13-session-content-search-opt-in.md) | 中文 + +## 问题 + +交付的 bundle 之前以启用状态挂载 SQLite 会话查询提供方的全文索引(`openAt: first-search`),因此每个默认部署都携带一个派生 FTS 索引,Web 侧边栏提供内容搜索。一个部署是否需要该索引——它的 node:sqlite 导入、每次搜索的来源对账和派生存储——是部署自身的选择,产品默认不携带它交付;面向模型的搜索工具此前已经是 opt-in 且未挂载(见[非默认交付决策](../feature/2026-08-02-session-search-not-shipped-default.md))。 + +通过卸载插件行来关闭该能力不可行。`ApiProxyService` 将 `sessionQuery` 声明为必需注入,没有该提供方时整个宿主 API 网关保持未加载,Web GUI 无法启动。会话日志导出通过 `ctx.sessionQuery.traceSession` 追踪子代理后代,子代理分叉也通过同一血缘追踪解析其 Workspace——两者都需要可选服务守卫加一个替代血缘来源,改动面大约扩大三倍,同时使精确读取在所有地方消失。 + +## 决策 + +内容搜索在提供方处强制关闭。`openAt: 'never'` 是 `@deepseek-ai/dsh-session-query-sqlite` 的第三个打开阶段:`searchSessions` 和 `searchEvents` 在任何请求规范化之前就以类型化的 `SESSION_QUERY_SEARCH_DISABLED` 代码失败,node:sqlite 绝不会被导入或打开,也不运行任何来源观察或对账。`ctx.sessionQuery` 上继承的全部精确读取、过滤和跟踪保持可用,因此会话导出、分叉的 Workspace 继承和标题读取不受影响。 + +`SESSION_QUERY_SEARCH_DISABLED` 加入封闭的 `SessionQueryErrorCode` 分类,`tool-session-query` 的服务边界将它映射为模型安全消息 `session search is disabled in this deployment`。 + +base bundle 在 `session-query-sqlite` 行上设置 `openAt: never`,web bundle 的重述保持该值;启用内容搜索只需在后续 patch 层用一行覆盖 `openAt`(`first-search` 或 `startup`),通常同时配一个持久 `path`。宿主 `session.search` 端点沿现有错误路径报告提供方失败,Web 侧边栏保持其既有降级:本地标题/工作区匹配加内容搜索不可用提示。CLI 兼容性测试固定交付的 `openAt: never` 行,而 Web e2e 脚手架保持内容搜索启用——其种子会话场景通过内容搜索导航,这些运行也是 opt-in 路径的装配级覆盖。 + +## 曾考虑的替代方案 + +- **卸载插件行**(在 base patch 中 `disabled: true`)——否决:api-gateway 的必需 `sessionQuery` 注入会使整个宿主 API 保持未加载,而把该注入改为可选需要守卫加上会话导出与分叉解析中的 header 遍历血缘回退。 +- **在消费方处关闭**(宿主 `session.search` 端点或侧边栏)——否决:强制应由做出决定的操作执行;opt-in 的模型工具或任何其他消费方仍会触达索引。 +- **在 `openAt` 旁增加独立布尔开关**——否决:打开阶段已经拥有"SQLite 何时启动"这一轴;`never` 延伸同一根轴,而不是增加一个可能与之矛盾的第二个旋钮。 + +## 结果 + +- 默认部署不运行任何派生索引:没有 node:sqlite 导入或实验性 SQLite 启动警告,没有对账工作,磁盘上没有派生数据库。侧边栏搜索只匹配会话标题和工作区名称。 +- 默认状态下的搜索失败是类型化且稳定的,调用方可以把部署选择与索引故障(`SESSION_QUERY_INDEX_FAILED`)区分开。 +- 重新启用内容搜索是逐部署配置而非代码改动,并原样恢复完整的 FTS 行为。 +- 挂载搜索工具但未覆盖 `openAt` 的组合,每次搜索调用都会得到模型安全的已禁用消息;启用工具意味着同时启用索引。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml index 1a4d923540..b9a8b2e9c8 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md -2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c -2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8 +2026-08-02-session-search-not-shipped-default.md: 60d1f424d5a9029a00b3f195d1f6e6baf2790d1e +2026-08-02-session-search-not-shipped-default.zh.md: 221613ae1aef93cd72734e4c328d822a88b64227 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md index c1bfd7f8e3..60d1f424d5 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -12,7 +12,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. -The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. +The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — with its full-text index off by default (`openAt: never`; the [content-search opt-in decision](../architecture/2026-08-13-session-content-search-opt-in.md)), and the Web overlay keeps its in-memory values for deployments that enable content search. Only the model-facing consumer is removed. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md index 9b80c54942..221613ae1a 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -12,7 +12,7 @@ Status: implemented 交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 -`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 +`ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`;其全文索引默认关闭(`openAt: never`,见[内容搜索 opt-in 决策](../architecture/2026-08-13-session-content-search-opt-in.md)),Web overlay 保留内存索引取值,供启用内容搜索的部署使用。被移除的只有面向模型的消费方。 ## 曾考虑的替代方案 diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts index b1477a63d0..c563c850e6 100644 --- a/apps/cli/tests/lazy-search-startup.compat.spec.ts +++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts @@ -21,15 +21,22 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const builtBin = join(repoRoot, 'apps/cli/lib/bin.js') const webDist = join(repoRoot, 'apps/web/dist/index.html') -// The web bundle's patch owns the session-query-sqlite lazy-open row. -const configPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml') +// Full-text session search ships off (`openAt: never` on both layers): the +// base patch carries the default, and the web restatement must not re-enable it. +const baseConfigPath = join(repoRoot, 'packages/bundle/base/cordis.patch.yml') +const webConfigPath = join(repoRoot, 'packages/bundle/web-app/cordis.patch.yml') const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1' interface ConfigRow { id?: string + disabled?: unknown config?: { openAt?: unknown } } +interface PatchEntry extends ConfigRow { + insert?: ConfigRow[] +} + const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { kind: 'scalar', construct: value => String(value), @@ -92,12 +99,20 @@ function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; cod } describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => { - it('boots and disposes the shipped composition without a SQLite startup warning', async () => { + it('boots and disposes the shipped composition with full-text search off by default', async () => { expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true) expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true) - const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[] - const searchRow = rows.find(row => row.id === 'session-query-sqlite') - expect(searchRow?.config?.openAt).toBe('first-search') + const baseRows = (yaml.load(await readFile(baseConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[]) + .flatMap(entry => entry.insert ?? [entry]) + const webRows = (yaml.load(await readFile(webConfigPath, 'utf8'), { schema: configSchema }) as PatchEntry[]) + .flatMap(entry => entry.insert ?? [entry]) + const baseRow = baseRows.find(row => row.id === 'session-query-sqlite') + const webRow = webRows.find(row => row.id === 'session-query-sqlite') + expect(baseRow?.config?.openAt).toBe('never') + expect(baseRow?.disabled).toBeUndefined() + // The web restatement keeps the shipped default; opting in is a later layer's override. + expect(webRow?.config?.openAt).toBe('never') + expect(webRow?.disabled).toBeUndefined() const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-')) try { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1c3fb67e85..970ff7b139 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -391,6 +391,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise = z.object({ path: z.string().required(), - openAt: z.union(['startup', 'first-search'] as const).default('startup'), + openAt: z.union(['startup', 'first-search', 'never'] as const).default('startup'), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), @@ -251,6 +257,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise> { + this._assertSearchEnabled() const normalized = normalizeSessionRequest(request, this.config) const signal = exec?.signal return this._serialized(signal, async () => { @@ -278,6 +285,7 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise { + this._assertSearchEnabled() const normalized = normalizeEventRequest(request, this.config) const signal = exec?.signal return this._serialized(signal, async () => { @@ -310,6 +318,19 @@ export class SqliteSessionQueryEngine extends SessionQueryEngine { return this._closePromise } + /** + * Refuse full-text calls under `openAt: 'never'` before any request + * normalization or SQLite work, so a disabled deployment never imports + * node:sqlite, opens the index, or observes sources. + */ + private _assertSearchEnabled(): void { + if (this.config.openAt !== 'never') return + throw new SessionQueryError( + 'session search is disabled: this deployment configures the session-query index with openAt "never"', + 'SESSION_QUERY_SEARCH_DISABLED', + ) + } + private async _close(): Promise { this._closed = true await this._tail @@ -991,7 +1012,7 @@ function resolveConfig(config: Config): ResolvedConfig { if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') } - const openPhases: readonly string[] = ['startup', 'first-search'] + const openPhases: readonly string[] = ['startup', 'first-search', 'never'] if (!openPhases.includes(resolved.openAt)) throw invalidConfig('openAt is not supported') assertPageLimit('defaultLimit', resolved.defaultLimit) assertPageLimit('maxLimit', resolved.maxLimit) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index dc791758f0..0a565fe021 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -231,6 +231,36 @@ describe('SQLite session search', () => { await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) }) + it('refuses search in never mode while inherited reads and traces keep working', async () => { + const path = await temporaryPath('never-mode.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' }) + const service = ctx.sessionQuery as SqliteSessionQueryEngine + expect(service.config.openAt).toBe('never') + + const parent = SessionId('never-parent') + const child = SessionId('never-child') + ctx.sessions.create(parent, { seed: messageEvents('never opened needle'), meta: { createdAt: 10 } }) + ctx.sessions.create(child, { meta: { parentSession: parent, createdAt: 20 } }) + + await expect(service.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED')) + await expect(service.searchEvents({ sessionId: parent, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SEARCH_DISABLED')) + + expect((await service.listSessions()).map(record => record.header.id).sort()) + .toEqual([child, parent]) + const lineage = await service.traceSession(parent) + expect(lineage.complete).toBe(true) + expect(lineage.descendants.map(node => node.session.header.id)).toEqual([child]) + + // The disabled index never touches the filesystem, in mount, use, or disposal. + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + await search.dispose() + await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('opens once on the first search and reuses readiness for later searches', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index a449799193..99039bcbfa 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -31,6 +31,7 @@ export type SessionQueryErrorCode = | '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' diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts index 9fbfc0c55c..98bc8e8dc9 100644 --- a/packages/session-query/tool-session-query/src/service-boundary.ts +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -71,6 +71,10 @@ const SAFE_SESSION_QUERY_FAILURES = { code: 'SESSION_QUERY_PERSISTENCE_FAILED', message: 'session history storage is unavailable', }, + SESSION_QUERY_SEARCH_DISABLED: { + code: 'SESSION_QUERY_SEARCH_DISABLED', + message: 'session search is disabled in this deployment', + }, SESSION_QUERY_SESSION_NOT_FOUND: { code: 'SESSION_QUERY_SESSION_NOT_FOUND', message: 'session was not found',