From e7dabe54d391cc7bd74bc8e6cca40a1a50be987c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 12 Sep 2026 01:44:30 -0400 Subject: [PATCH] feat(ai-vault-search): define public contract and service seam --- .gitignore | 1 + .../agent-session-search-contract.md | 117 ++++++++++++++++++ .../session-search-engine-types.ts | 14 +-- .../ai-vault-search/session-search-engine.ts | 4 + .../session-search-service-registry.test.ts | 105 ++++++++++++++++ .../session-search-service-registry.ts | 69 +++++++++++ .../session-search-service.test.ts | 105 ++++++++++++++++ .../ai-vault-search/session-search-service.ts | 95 ++++++++++++++ src/shared/ai-vault-search-client.ts | 76 ++++++++++++ src/shared/ai-vault-search-contract.test.ts | 72 +++++++++++ src/shared/ai-vault-search-contract.ts | 103 +++++++++++++++ src/shared/ai-vault-search-limit.ts | 8 ++ src/shared/ai-vault-search-test-fixture.ts | 43 +++++++ src/shared/ai-vault-search-transport.ts | 17 +++ src/shared/ai-vault-search-types.ts | 19 +++ 15 files changed, 839 insertions(+), 9 deletions(-) create mode 100644 docs/reference/agent-session-search-contract.md create mode 100644 src/main/ai-vault-search/session-search-service-registry.test.ts create mode 100644 src/main/ai-vault-search/session-search-service-registry.ts create mode 100644 src/main/ai-vault-search/session-search-service.test.ts create mode 100644 src/main/ai-vault-search/session-search-service.ts create mode 100644 src/shared/ai-vault-search-client.ts create mode 100644 src/shared/ai-vault-search-contract.test.ts create mode 100644 src/shared/ai-vault-search-contract.ts create mode 100644 src/shared/ai-vault-search-limit.ts create mode 100644 src/shared/ai-vault-search-test-fixture.ts create mode 100644 src/shared/ai-vault-search-transport.ts create mode 100644 src/shared/ai-vault-search-types.ts diff --git a/.gitignore b/.gitignore index cf2f7244eb3..8fb5fce8f42 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ docs/** !docs/reference/ !docs/reference/agent-pty-transcript-capture.md !docs/reference/agent-session-search-query-tuning.md +!docs/reference/agent-session-search-contract.md !docs/reference/agent-status-store.md !docs/reference/antigravity-readiness-evidence.md !docs/reference/git-compatibility.md diff --git a/docs/reference/agent-session-search-contract.md b/docs/reference/agent-session-search-contract.md new file mode 100644 index 00000000000..33fea0ea0d2 --- /dev/null +++ b/docs/reference/agent-session-search-contract.md @@ -0,0 +1,117 @@ +# Agent session search contract + +`AiVaultSearchRequest`, `AiVaultSearchResponse`, `AiVaultSearchHit`, and +`AiVaultSearchStatus` are defined in `src/shared/ai-vault-search-types.ts` and +validated by `src/shared/ai-vault-search-contract.ts`. + +## Search and pagination + +- Tool output beyond 3,072 characters per row is not indexed and not searchable; user and assistant text is indexed in full. +- A page cursor outstanding during a retention purge is refused once as `stale-cursor`; the client re-issues page 1. +- A phrase match across a chunk boundary of a long message is not supported. + +`aiVault.searchSessions(request)` accepts `query`, optional `scope` +(`conversation` or `all`, default `all`), `freshness` (`indexed` or +`wait-until-current`, default `indexed`), `limit`, opaque `cursor`, `filters`, +and `debug` (default false). Conversation scope searches user and assistant text. +Filters accept `agents`, `scopePaths`, ISO `since`, and `sort` (`relevance` or +`newest`). Paths refer to the execution host and work for folders without Git. +Legacy `tier` and `refresh` fields are accepted and discarded; they do not change +the defaults. Limits use the engine's resolver: default 20, integers clamped to +1–100, fractional numbers use the default. Long queries reach the engine so it +can report truncation rather than fail validation. + +Results contain `kind: 'results'`, `hits`, `page: { cursor, hasMore }`, +`generation`, `truncated: { candidates, snippets, query, freshness }`, and +`durationMs`. `snippets` is a count; the other truncation fields are booleans. +`durationMs` measures the engine search, excluding any reconciliation wait. +`debug: true` adds `debug: { route, repairedTerms?, plannerReport }`; the report +contains `route`, optional `repairedTerms`, and `scope`. Diagnostics never appear +at the top level. Status is never attached to search results. + +A cursor belongs to one query and one host's index generation. Query, scope, +filters, and sorting must remain the same; page size may change. Writes that +advance the generation can invalidate it, including retention purges. A refused +cursor yields `{ kind: 'stale-cursor', generation, expectedGeneration? }` and the +client discards it and issues page 1 without a cursor. Reusing that refused cursor +continues to fail; there is no server-side cursor acknowledgement state. +Malformed cursors and cursors for a different query yield +`{ kind: 'malformed-cursor' }`. Generation checks also reject a first page if the +index changes during retrieval. Generation is a fence, not a retained snapshot: +a client cannot ask the host to recreate a previous generation. + +Pages are per host only. A remote client asks one host at a time. Ordering is +local to that host's query; merged cross-host ordering and a merged cross-host +cursor are out of scope. Clients must discard cursors when changing hosts. + +## Evidence and exposure + +Each hit carries agent, session ID, title, cwd, branch, updated time, message +count, score, source, and evidence. Evidence contains snippet, role, and timestamp; +it is null for operator-only matches that have no text evidence. Snippet matches +use `[[` and `]]` markers. Source presence is `present`, `unverifiable`, or +`missing`; the current engine emits the first two. Loss of contact does not prove +a source missing. + +`redactForTransport(hit, transport)` is the exposure policy: + +| Transport | filePath / codexHome | resumeCommand | +| ----------------------------------------- | ------------------------------------ | --------------------------------- | +| Desktop IPC on the same machine | Included when known, under source | Included only for present sources | +| Runtime RPC on the same machine | Included when known, under source | Included only for present sources | +| Relay or paired runtime/web/mobile client | Withheld; source keeps presence only | Withheld | + +`cwd`, titles, snippets, and other hit metadata remain visible to paired clients. +Snippets cross the authenticated transport as indexed; this contract does not +apply an observability redactor to transcript content. A missing Codex home is +omitted. Resume commands reuse the command stored by the transcript reader, +constructed by the sidebar's `buildAiVaultResumeCommand`; this layer does not +construct commands or execute them. The runtime uses its authenticated +`clientKind` context to distinguish paired clients from same-machine RPC, never +a request-supplied locality flag. The receiving remote client also applies the +same exposure function. + +## Status, freshness, and availability + +`aiVault.searchStatus()` returns `enabled`, `phase` (`idle`, `indexing`, `current`, +`degraded`, or `closed`), `filesIndexed`, `filesDue`, `filesFailed`, `degradedRoots` +(`root` and `reason`), `lastReconcileAt`, `lastSweepCompletedAt`, and `generation`. +Times are milliseconds since epoch or null. These are the indexer's observations; +an indexed row is not a new filesystem verification. Status roots and reasons +are host diagnostics and remain visible to authenticated paired callers. + +`wait-until-current` calls `service.reconcile()` before searching. The adapter +uses `indexer.reconcile({ full: false })`. After five seconds the endpoint searches +anyway and sets `truncated.freshness: true` on results. It does not cancel the +host's reconciliation. Completion before the deadline leaves the flag false; +a reconciliation error before the deadline propagates. The indexer's bounded +recent pass is not a promise that the entire historical corpus was swept. + +Search unavailability is a value: +`{ kind: 'unavailable', reason: 'disabled' | 'not-ready' | 'no-service' }`. +No registered service returns `no-service`. Status without a service has +`enabled: false`, `phase: 'idle'`, zero counts and generation, empty degraded roots, +and null timestamps. This is a sentinel for an absent service, not a claim of an +empty, current index. A registered service may report disabled or not-ready. + +## Boundaries and compatibility + +- Desktop: `aiVault:searchSessions` and `aiVault:searchStatus`, via preload. +- Runtime and relay: `aiVault.searchSessions` and `aiVault.searchStatus`. +- Desktop preload optionally accepts an SSH target ID as a separate routing + argument. It addresses exactly that relay; missing connections never fall back + to the local index. The web preload addresses its selected paired runtime and + rejects an SSH routing argument. + +Requests and responses are parsed where received from another process. Existing +relay JSON-RPC request/response framing needs no new stream opcode. Following the +existing relay method probe pattern, an old host's explicit `-32601` refusal (or +runtime `method_not_found`) maps to `unavailable/no-service` on the client; status +uses the absent-service sentinel above. Transport failures, authentication errors, +and invalid payloads remain errors. Unknown request fields are stripped for wire +compatibility. + +The process-local `setSessionSearchService(service | null)` registry is the only +production seam in this PR. Tests use fake services and a real synthetic store. +Nothing constructs an engine or indexer in production. PR 3b owns process +lifecycle, consent/settings application, and registration of the production service. diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts index 055bbe001af..57229deb90a 100644 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -5,8 +5,11 @@ import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consu // PR 5 owns the public contract and lifts what a caller may actually receive; // until then a field can be added, renamed or dropped without a compat story. -export const SESSION_SEARCH_LIMIT_DEFAULT = 20 -export const SESSION_SEARCH_LIMIT_MAX = 100 +export { + SESSION_SEARCH_LIMIT_DEFAULT, + SESSION_SEARCH_LIMIT_MAX, + resolveSessionSearchLimit +} from '../../shared/ai-vault-search-limit' // Longer than this is not a query, and FTS5 pays for every term it plans. export const SESSION_SEARCH_QUERY_MAX_LENGTH = 512 @@ -141,10 +144,3 @@ export type SessionSearchResponse = { generation: number durationMs: number } - -export function resolveSessionSearchLimit(limit: number | undefined): number { - // Why clamped here and not at the caller: a non-positive limit becomes - // `slice(0, -1)`, which silently drops the last hit of every page. - const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT - return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) -} diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts index ac7ddf9a3da..836842b9160 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -89,6 +89,10 @@ export class SessionSearchEngine { this.retrieval = new SessionSearchRetrieval(this.db) } + generation(): number { + return readIndexGeneration(this.db) + } + search(request: SessionSearchRequest): SessionSearchResponse { const startedAt = performance.now() ensureSessionSearchQuerySchema(this.db) diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts new file mode 100644 index 00000000000..1c86b66fba8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' +import { + setSessionSearchService, + searchSessionService, + sessionSearchServiceStatus +} from './session-search-service-registry' + +afterEach(() => { + setSessionSearchService(null) + vi.useRealTimers() +}) + +describe('session search service registry', () => { + it('answers without constructing an indexer and validates even when unavailable', async () => { + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason: 'no-service' + }) + expect(await sessionSearchServiceStatus()).toMatchObject({ + enabled: false, + phase: 'idle', + generation: 0 + }) + await expect(searchSessionService({ query: 42 }, 'ipc')).rejects.toThrow() + }) + it('searches indexed data by default, drops legacy options and suppresses unsolicited diagnostics', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + const result = await searchSessionService( + { query: 'needle', tier: 'conversation', refresh: true }, + 'ipc' + ) + expect(service.reconcile).not.toHaveBeenCalled() + expect(service.search).toHaveBeenCalledWith({ query: 'needle', limit: 20 }) + expect(result).not.toHaveProperty('debug') + expect(await searchSessionService({ query: 'needle', debug: true }, 'ipc')).toHaveProperty( + 'debug' + ) + }) + it('waits for reconcile before search, and clears its timeout', async () => { + vi.useFakeTimers() + const service = fakeSearchService() + let release!: () => void + service.reconcile.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + setSessionSearchService(service) + const result = searchSessionService( + { query: 'needle', freshness: 'wait-until-current' }, + 'runtime' + ) + await Promise.resolve() + expect(service.search).not.toHaveBeenCalled() + release() + expect(await result).toMatchObject({ kind: 'results', truncated: { freshness: false } }) + expect(vi.getTimerCount()).toBe(0) + }) + it('searches after the default five-second bound and observes a late rejection', async () => { + vi.useFakeTimers() + const service = fakeSearchService() + let reject!: (error: Error) => void + service.reconcile.mockImplementation( + () => + new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + ) + setSessionSearchService(service) + const result = searchSessionService( + { query: 'needle', freshness: 'wait-until-current' }, + 'relay' + ) + await vi.advanceTimersByTimeAsync(4_999) + expect(service.search).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await result).toMatchObject({ + kind: 'results', + truncated: { freshness: true }, + hits: [expect.objectContaining({ source: { presence: 'present' } })] + }) + reject(new Error('late failure')) + await Promise.resolve() + expect(vi.getTimerCount()).toBe(0) + }) + it('propagates reconciliation failures before timeout and service unavailability', async () => { + const service = fakeSearchService() + setSessionSearchService(service) + service.reconcile.mockRejectedValue(new Error('cannot reconcile')) + await expect( + searchSessionService({ query: 'needle', freshness: 'wait-until-current' }, 'ipc') + ).rejects.toThrow('cannot reconcile') + expect(service.search).not.toHaveBeenCalled() + for (const reason of ['disabled', 'not-ready'] as const) { + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason + }) + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts new file mode 100644 index 00000000000..06a01aa4fa0 --- /dev/null +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -0,0 +1,69 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusRequestSchema, + AiVaultSearchStatusSchema +} from '../../shared/ai-vault-search-contract' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' +import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' +import { + redactForTransport, + type SessionSearchTransport +} from '../../shared/ai-vault-search-transport' +import type { SessionSearchService } from './session-search-service' + +let service: SessionSearchService | null = null + +export function setSessionSearchService(next: SessionSearchService | null): void { + service = next +} + +export async function searchSessionService( + raw: unknown, + transport: SessionSearchTransport, + freshnessTimeoutMs = 5_000 +): Promise { + const request = AiVaultSearchRequestSchema.parse(raw) + const current = service + if (!current) { + return { kind: 'unavailable', reason: 'no-service' } + } + const freshness = + request.freshness === 'wait-until-current' + ? await reconcileWithin(current, freshnessTimeoutMs) + : false + const result = AiVaultSearchResponseSchema.parse(await current.search(request)) + if (result.kind !== 'results') { + return result + } + const { debug, ...fields } = result + return { + ...fields, + hits: result.hits.map((hit) => redactForTransport(hit, transport)), + truncated: { ...result.truncated, freshness: result.truncated.freshness || freshness }, + ...(request.debug && debug ? { debug } : {}) + } +} + +export async function sessionSearchServiceStatus(raw: unknown = {}): Promise { + AiVaultSearchStatusRequestSchema.parse(raw) + return AiVaultSearchStatusSchema.parse( + service ? await service.status() : unavailableSessionSearchStatus() + ) +} + +async function reconcileWithin(current: SessionSearchService, timeoutMs: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + Promise.resolve() + .then(() => current.reconcile()) + .then(() => false), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/ai-vault-search/session-search-service.test.ts b/src/main/ai-vault-search/session-search-service.test.ts new file mode 100644 index 00000000000..a4729a6f00a --- /dev/null +++ b/src/main/ai-vault-search/session-search-service.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from './session-search-engine-test-fixture' +import { createSessionSearchService } from './session-search-service' +import { + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from '../../shared/ai-vault-search-contract' +import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' + +let harness: SessionSearchHarness | undefined + +afterEach(async () => { + await harness?.close() + harness = undefined +}) + +async function fixture() { + harness = await openSessionSearchHarness('public-contract') + const { enabled: _enabled, generation: _generation, ...status } = unavailableSessionSearchStatus() + const indexer = { status: () => status, reconcile: vi.fn(async () => {}) } + const service = createSessionSearchService({ engine: harness.engine, indexer }) + return { ...harness, service, indexer } +} + +describe('real index to public service adapter', () => { + it('pages a real store, maps evidence and diagnostics, and rejects stale or malformed cursors', async () => { + const { db, store, service } = await fixture() + addSyntheticSession(db, { id: 1 }) + addSyntheticSession(db, { id: 2, filePath: null }) + const first = await service.search({ query: 'needle', limit: 1, debug: true }) + expect(AiVaultSearchResponseSchema.parse(first)).toEqual(first) + expect(first.kind).toBe('results') + if (first.kind !== 'results') { + throw new Error('Expected results') + } + expect(first.debug?.plannerReport.scope).toBe('all') + expect(first).not.toHaveProperty('route') + expect(first).not.toHaveProperty('tier') + expect(first.page.hasMore).toBe(true) + const next = await service.search({ query: 'needle', cursor: first.page.cursor!, limit: 1 }) + expect(next.kind).toBe('results') + if (next.kind !== 'results') { + throw new Error('Expected results') + } + expect(next.hits[0].sessionId).not.toBe(first.hits[0].sessionId) + expect(next).not.toHaveProperty('debug') + const hits = [...first.hits, ...next.hits] + expect(hits.find((hit) => hit.source.presence === 'present')?.resumeCommand).toBe('resume') + expect(hits.find((hit) => hit.source.presence === 'unverifiable')).not.toHaveProperty( + 'resumeCommand' + ) + expect(await service.search({ query: 'other', cursor: first.page.cursor! })).toEqual({ + kind: 'malformed-cursor' + }) + await store.purgeOlderThan(1740000000001) + expect(await service.search({ query: 'needle', cursor: first.page.cursor! })).toMatchObject({ + kind: 'stale-cursor', + expectedGeneration: first.generation + }) + expect(await service.search({ query: 'needle', cursor: '' })).toEqual({ + kind: 'malformed-cursor' + }) + expect(await service.search({ query: 'needle', cursor: 'garbage' })).toEqual({ + kind: 'malformed-cursor' + }) + expect(await service.search({ query: 'needle' })).toMatchObject({ + kind: 'results', + hits: [expect.objectContaining({ sessionId: '2' })] + }) + }) + it('preserves null evidence for folder operators and delegates recent reconciliation', async () => { + const { db, service, indexer } = await fixture() + addSyntheticSession(db, { id: 1, cwd: '/folder/no-git-required' }) + const result = await service.search({ query: 'path:no-git-required' }) + expect(result).toMatchObject({ + kind: 'results', + hits: [expect.objectContaining({ evidence: null })] + }) + await service.reconcile() + expect(indexer.reconcile).toHaveBeenCalledExactlyOnceWith({ full: false }) + const status = await service.status() + expect(AiVaultSearchStatusSchema.parse(status)).toEqual(status) + expect(status.generation).toBeGreaterThan(0) + }) + it('keeps tool text out of conversation scope and reports query truncation', async () => { + const { db, service } = await fixture() + addSyntheticSession(db, { id: 1, role: 'tool', text: 'needle' }) + expect(await service.search({ query: 'needle', scope: 'conversation' })).toMatchObject({ + kind: 'results', + hits: [] + }) + expect(await service.search({ query: 'needle' })).toMatchObject({ + kind: 'results', + hits: [expect.anything()] + }) + expect(await service.search({ query: 'needle '.repeat(100) })).toMatchObject({ + kind: 'results', + truncated: { query: true } + }) + }) +}) diff --git a/src/main/ai-vault-search/session-search-service.ts b/src/main/ai-vault-search/session-search-service.ts new file mode 100644 index 00000000000..0f9cf6c615e --- /dev/null +++ b/src/main/ai-vault-search/session-search-service.ts @@ -0,0 +1,95 @@ +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from '../../shared/ai-vault-search-types' +import type { SessionSearchEngine } from './session-search-engine' +import type { SessionSearchIndexer } from './session-search-indexer' +import { SessionSearchCursorError } from './session-search-page-cursor' + +export type SessionSearchService = { + search(req: AiVaultSearchRequest): Promise + status(): Promise + reconcile(): Promise +} + +export function createSessionSearchService({ + engine, + indexer +}: { + engine: SessionSearchEngine + indexer: Pick +}): SessionSearchService { + return { + reconcile: () => indexer.reconcile({ full: false }), + status: async () => ({ enabled: true, ...indexer.status(), generation: engine.generation() }), + search: async (request) => { + if (request.cursor === '') { + return { kind: 'malformed-cursor' } + } + try { + const result = engine.search(request) + return { + kind: 'results', + hits: result.hits.map( + ({ + filePath, + codexHome, + source, + evidence, + resumeCommand, + duplicateCount: _duplicateCount, + ...hit + }) => ({ + ...hit, + source: { presence: source, filePath, ...(codexHome === null ? {} : { codexHome }) }, + evidence: + evidence === null + ? null + : { + snippet: evidence.snippet, + role: evidence.role, + timestamp: evidence.timestamp + }, + ...(source === 'present' ? { resumeCommand } : {}) + }) + ), + page: result.page, + generation: result.generation, + truncated: { ...result.truncated, freshness: false }, + durationMs: result.durationMs, + ...(request.debug + ? { + debug: { + route: result.planner.route, + ...(result.planner.repairedTerms + ? { repairedTerms: result.planner.repairedTerms } + : {}), + plannerReport: { + route: result.planner.route, + scope: result.planner.tier, + ...(result.planner.repairedTerms + ? { repairedTerms: result.planner.repairedTerms } + : {}) + } + } + } + : {}) + } + } catch (error) { + if (!(error instanceof SessionSearchCursorError)) { + throw error + } + return error.rejection === 'stale-generation' + ? { + kind: 'stale-cursor', + generation: error.actualGeneration, + ...(error.expectedGeneration === undefined + ? {} + : { expectedGeneration: error.expectedGeneration }) + } + : { kind: 'malformed-cursor' } + } + } + } +} diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts new file mode 100644 index 00000000000..069ace0723a --- /dev/null +++ b/src/shared/ai-vault-search-client.ts @@ -0,0 +1,76 @@ +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' +import type { + AiVaultSearchRequest, + AiVaultSearchResponse, + AiVaultSearchStatus +} from './ai-vault-search-types' +import { redactForTransport, type SessionSearchTransport } from './ai-vault-search-transport' + +export function unavailableSessionSearchStatus(): AiVaultSearchStatus { + return { + enabled: false, + phase: 'idle', + filesIndexed: 0, + filesDue: 0, + filesFailed: 0, + degradedRoots: [], + lastReconcileAt: null, + lastSweepCompletedAt: null, + generation: 0 + } +} + +// Only an explicit unknown-method refusal proves the old host lacks this surface. +function isUnknownMethod(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('code' in error)) { + return false + } + return error.code === -32601 || error.code === 'method_not_found' +} + +export function createSessionSearchClient( + call: (method: string, params: unknown) => Promise, + transport: SessionSearchTransport +): { + searchSessions(request: AiVaultSearchRequest): Promise + searchStatus(): Promise +} { + return { + searchSessions: async (request) => { + const parsed = AiVaultSearchRequestSchema.parse(request) + let raw: unknown + try { + raw = await call('aiVault.searchSessions', parsed) + } catch (error) { + if (isUnknownMethod(error)) { + return { kind: 'unavailable', reason: 'no-service' } + } + throw error + } + const result = AiVaultSearchResponseSchema.parse(raw) + if (result.kind !== 'results') { + return result + } + const { debug, ...fields } = result + return { + ...fields, + hits: result.hits.map((hit) => redactForTransport(hit, transport)), + ...(parsed.debug && debug ? { debug } : {}) + } + }, + searchStatus: async () => { + try { + return AiVaultSearchStatusSchema.parse(await call('aiVault.searchStatus', {})) + } catch (error) { + if (isUnknownMethod(error)) { + return unavailableSessionSearchStatus() + } + throw error + } + } + } +} diff --git a/src/shared/ai-vault-search-contract.test.ts b/src/shared/ai-vault-search-contract.test.ts new file mode 100644 index 00000000000..99dd5020790 --- /dev/null +++ b/src/shared/ai-vault-search-contract.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' +import { searchHit, searchResults } from './ai-vault-search-test-fixture' +import { redactForTransport } from './ai-vault-search-transport' +import { unavailableSessionSearchStatus } from './ai-vault-search-client' + +describe('session search public contract', () => { + it('drops legacy fields without letting them override scope or freshness', () => { + expect( + AiVaultSearchRequestSchema.parse({ query: 'needle', tier: 'conversation', refresh: true }) + ).toEqual({ query: 'needle', limit: 20 }) + }) + it.each([ + [0, 1], + [-4, 1], + [200, 100], + [2.5, 20], + [undefined, 20] + ])('clamps %s to %s', (limit, expected) => { + expect(AiVaultSearchRequestSchema.parse({ query: 'needle', limit }).limit).toBe(expected) + }) + it('allows the engine to report long-query truncation', () => { + const query = 'needle '.repeat(100) + expect(AiVaultSearchRequestSchema.parse({ query }).query).toBe(query) + }) + it('validates every response variant and separate status', () => { + for (const response of [ + searchResults(), + { kind: 'malformed-cursor' }, + { kind: 'stale-cursor', generation: 2, expectedGeneration: 1 }, + ...['disabled', 'not-ready', 'no-service'].map((reason) => ({ kind: 'unavailable', reason })) + ]) { + expect(AiVaultSearchResponseSchema.parse(response)).toEqual(response) + } + expect(AiVaultSearchStatusSchema.parse(unavailableSessionSearchStatus())).toEqual( + unavailableSessionSearchStatus() + ) + expect(AiVaultSearchResponseSchema.safeParse({ kind: 'results', hits: [] }).success).toBe(false) + }) + it('never accepts resume commands for an unverified or missing source', () => { + for (const presence of ['unverifiable', 'missing'] as const) { + const response = searchResults() + response.hits[0].source.presence = presence + expect(AiVaultSearchResponseSchema.safeParse(response).success).toBe(false) + } + }) + it.each(['ipc', 'runtime', 'relay'] as const)( + 'enforces the %s exposure policy without mutating the hit', + (transport) => { + const hit = searchHit() + const original = structuredClone(hit) + const result = redactForTransport(hit, transport) + expect(hit).toEqual(original) + expect(result.cwd).toBe('/host/folder') + if (transport === 'relay') { + expect(result.source).toEqual({ presence: 'present' }) + expect(result).not.toHaveProperty('resumeCommand') + } else { + expect(result).toEqual(hit) + } + for (const presence of ['unverifiable', 'missing'] as const) { + expect( + redactForTransport({ ...hit, source: { ...hit.source, presence } }, transport) + ).not.toHaveProperty('resumeCommand') + } + } + ) +}) diff --git a/src/shared/ai-vault-search-contract.ts b/src/shared/ai-vault-search-contract.ts new file mode 100644 index 00000000000..ef48497dc7a --- /dev/null +++ b/src/shared/ai-vault-search-contract.ts @@ -0,0 +1,103 @@ +import { resolveSessionSearchLimit, SESSION_SEARCH_LIMIT_MAX } from './ai-vault-search-limit' +import { z } from 'zod' +import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from './ai-vault-types' + +export const AiVaultSearchFiltersSchema = z.object({ + agents: z.array(z.enum(AI_VAULT_AGENTS)).optional(), + scopePaths: z.array(z.string().min(1).max(4096)).max(AI_VAULT_SCOPE_PATHS_MAX_COUNT).optional(), + since: z.string().datetime({ offset: true }).optional(), + sort: z.enum(['relevance', 'newest']).optional() +}) + +// Strip unknown fields so legacy tier/refresh are accepted without affecting the query. +export const AiVaultSearchRequestSchema = z.object({ + query: z.string(), + scope: z.enum(['conversation', 'all']).optional(), + freshness: z.enum(['indexed', 'wait-until-current']).optional(), + limit: z.number().optional().transform(resolveSessionSearchLimit), + cursor: z.string().optional(), + filters: AiVaultSearchFiltersSchema.optional(), + debug: z.boolean().optional() +}) + +export const AiVaultSearchSourceSchema = z.object({ + presence: z.enum(['present', 'unverifiable', 'missing']), + filePath: z.string().optional(), + codexHome: z.string().optional() +}) +export const AiVaultSearchEvidenceSchema = z.object({ + snippet: z.string(), + role: z.enum(['user', 'assistant', 'tool', 'system', 'unknown']), + timestamp: z.string().nullable() +}) +export const AiVaultSearchHitSchema = z + .object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + updatedAt: z.string().nullable(), + messageCount: z.number().int().nonnegative(), + score: z.number(), + source: AiVaultSearchSourceSchema, + evidence: AiVaultSearchEvidenceSchema.nullable(), + resumeCommand: z.string().optional() + }) + .refine((hit) => hit.source.presence === 'present' || hit.resumeCommand === undefined, { + message: 'Only present sources may have a resume command' + }) +export const AiVaultSearchPageSchema = z.object({ + cursor: z.string().nullable(), + hasMore: z.boolean() +}) +export const AiVaultSearchTruncationSchema = z.object({ + candidates: z.boolean(), + snippets: z.number().int().nonnegative(), + query: z.boolean(), + freshness: z.boolean() +}) +const routeSchema = z.enum(['phrase', 'and', 'or', 'typo+phrase', 'typo+and', 'typo+or']) +export const AiVaultSearchPlannerReportSchema = z.object({ + route: routeSchema, + repairedTerms: z.array(z.string()).optional(), + scope: z.enum(['conversation', 'all']) +}) +export const AiVaultSearchDebugSchema = z.object({ + route: routeSchema, + repairedTerms: z.array(z.string()).optional(), + plannerReport: AiVaultSearchPlannerReportSchema +}) +export const AiVaultSearchResponseSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('results'), + hits: z.array(AiVaultSearchHitSchema).max(SESSION_SEARCH_LIMIT_MAX), + page: AiVaultSearchPageSchema, + generation: z.number().int().nonnegative(), + truncated: AiVaultSearchTruncationSchema, + durationMs: z.number().nonnegative(), + debug: AiVaultSearchDebugSchema.optional() + }), + z.object({ + kind: z.literal('stale-cursor'), + generation: z.number().int().nonnegative(), + expectedGeneration: z.number().int().nonnegative().optional() + }), + z.object({ kind: z.literal('malformed-cursor') }), + z.object({ + kind: z.literal('unavailable'), + reason: z.enum(['disabled', 'not-ready', 'no-service']) + }) +]) +export const AiVaultSearchStatusRequestSchema = z.object({}) +export const AiVaultSearchStatusSchema = z.object({ + enabled: z.boolean(), + phase: z.enum(['idle', 'indexing', 'current', 'degraded', 'closed']), + filesIndexed: z.number().int().nonnegative(), + filesDue: z.number().int().nonnegative(), + filesFailed: z.number().int().nonnegative(), + degradedRoots: z.array(z.object({ root: z.string(), reason: z.string() })), + lastReconcileAt: z.number().nullable(), + lastSweepCompletedAt: z.number().nullable(), + generation: z.number().int().nonnegative() +}) diff --git a/src/shared/ai-vault-search-limit.ts b/src/shared/ai-vault-search-limit.ts new file mode 100644 index 00000000000..8563046fdfe --- /dev/null +++ b/src/shared/ai-vault-search-limit.ts @@ -0,0 +1,8 @@ +export const SESSION_SEARCH_LIMIT_DEFAULT = 20 +export const SESSION_SEARCH_LIMIT_MAX = 100 + +export function resolveSessionSearchLimit(limit: number | undefined): number { + // Non-positive limits otherwise make slice silently drop hits. + const requested = Number.isInteger(limit) ? (limit as number) : SESSION_SEARCH_LIMIT_DEFAULT + return Math.min(Math.max(1, requested), SESSION_SEARCH_LIMIT_MAX) +} diff --git a/src/shared/ai-vault-search-test-fixture.ts b/src/shared/ai-vault-search-test-fixture.ts new file mode 100644 index 00000000000..40647ba8605 --- /dev/null +++ b/src/shared/ai-vault-search-test-fixture.ts @@ -0,0 +1,43 @@ +import { vi } from 'vitest' +import type { AiVaultSearchHit, AiVaultSearchResponse } from './ai-vault-search-types' +import { unavailableSessionSearchStatus } from './ai-vault-search-client' + +export function searchHit(): AiVaultSearchHit { + return { + agent: 'codex', + sessionId: 'host-session', + title: 'Indexed conversation', + cwd: '/host/folder', + branch: null, + updatedAt: null, + messageCount: 2, + score: 1, + source: { presence: 'present', filePath: '/host/transcript.jsonl', codexHome: '/host/codex' }, + evidence: { role: 'user', timestamp: null, snippet: 'a [[needle]]' }, + resumeCommand: 'host-resume-command' + } +} + +export function searchResults(): Extract { + return { + kind: 'results', + hits: [searchHit()], + page: { cursor: null, hasMore: false }, + generation: 7, + truncated: { candidates: false, snippets: 0, query: false, freshness: false }, + durationMs: 1, + debug: { route: 'phrase', plannerReport: { route: 'phrase', scope: 'all' } } + } +} + +export function fakeSearchService() { + return { + search: vi.fn(async () => searchResults() as AiVaultSearchResponse), + status: vi.fn(async () => ({ + ...unavailableSessionSearchStatus(), + enabled: true, + generation: 7 + })), + reconcile: vi.fn(async () => {}) + } +} diff --git a/src/shared/ai-vault-search-transport.ts b/src/shared/ai-vault-search-transport.ts new file mode 100644 index 00000000000..e99d26db628 --- /dev/null +++ b/src/shared/ai-vault-search-transport.ts @@ -0,0 +1,17 @@ +import type { AiVaultSearchHit } from './ai-vault-search-types' + +export type SessionSearchTransport = 'ipc' | 'runtime' | 'relay' + +export function redactForTransport( + hit: AiVaultSearchHit, + transport: SessionSearchTransport +): AiVaultSearchHit { + const { resumeCommand, source, ...fields } = hit + return { + ...fields, + source: transport === 'relay' ? { presence: source.presence } : { ...source }, + ...(transport !== 'relay' && source.presence === 'present' && resumeCommand !== undefined + ? { resumeCommand } + : {}) + } +} diff --git a/src/shared/ai-vault-search-types.ts b/src/shared/ai-vault-search-types.ts new file mode 100644 index 00000000000..76a25c43fd8 --- /dev/null +++ b/src/shared/ai-vault-search-types.ts @@ -0,0 +1,19 @@ +import type { z } from 'zod' +import type { + AiVaultSearchRequestSchema, + AiVaultSearchResponseSchema, + AiVaultSearchHitSchema, + AiVaultSearchStatusSchema +} from './ai-vault-search-contract' + +/** + * Tool output beyond 3,072 characters per row is not indexed and not searchable; user and assistant text is indexed in full. + * A page cursor outstanding during a retention purge is refused once as `stale-cursor`; the client re-issues page 1. + * A phrase match across a chunk boundary of a long message is not supported. + */ +export type AiVaultSearchRequest = z.input +/** Pages belong to one host; callers re-issue page 1 after a stale cursor. */ +export type AiVaultSearchResponse = z.infer +/** Evidence is null for operator-only matches; remote callers receive source presence only. */ +export type AiVaultSearchHit = z.infer +export type AiVaultSearchStatus = z.infer