feat(ai-vault-search): define public contract and service seam

This commit is contained in:
Jinwoo-H
2026-09-12 01:44:30 -04:00
parent fa3d29fa11
commit e7dabe54d3
15 changed files with 839 additions and 9 deletions
+1
View File
@@ -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
@@ -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.
@@ -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)
}
@@ -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)
@@ -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
})
}
})
})
@@ -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<AiVaultSearchResponse> {
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<AiVaultSearchStatus> {
AiVaultSearchStatusRequestSchema.parse(raw)
return AiVaultSearchStatusSchema.parse(
service ? await service.status() : unavailableSessionSearchStatus()
)
}
async function reconcileWithin(current: SessionSearchService, timeoutMs: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
Promise.resolve()
.then(() => current.reconcile())
.then(() => false),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), timeoutMs)
})
])
} finally {
clearTimeout(timer)
}
}
@@ -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 }
})
})
})
@@ -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<AiVaultSearchResponse>
status(): Promise<AiVaultSearchStatus>
reconcile(): Promise<void>
}
export function createSessionSearchService({
engine,
indexer
}: {
engine: SessionSearchEngine
indexer: Pick<SessionSearchIndexer, 'status' | 'reconcile'>
}): 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' }
}
}
}
}
+76
View File
@@ -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<unknown>,
transport: SessionSearchTransport
): {
searchSessions(request: AiVaultSearchRequest): Promise<AiVaultSearchResponse>
searchStatus(): Promise<AiVaultSearchStatus>
} {
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
}
}
}
}
@@ -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')
}
}
)
})
+103
View File
@@ -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()
})
+8
View File
@@ -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)
}
@@ -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<AiVaultSearchResponse, { kind: 'results' }> {
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 () => {})
}
}
+17
View File
@@ -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 }
: {})
}
}
+19
View File
@@ -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<typeof AiVaultSearchRequestSchema>
/** Pages belong to one host; callers re-issue page 1 after a stale cursor. */
export type AiVaultSearchResponse = z.infer<typeof AiVaultSearchResponseSchema>
/** Evidence is null for operator-only matches; remote callers receive source presence only. */
export type AiVaultSearchHit = z.infer<typeof AiVaultSearchHitSchema>
export type AiVaultSearchStatus = z.infer<typeof AiVaultSearchStatusSchema>