mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
feat(ai-vault-search): define public contract and service seam
This commit is contained in:
@@ -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' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 () => {})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user