feat(session-query): add SQLite full-text search

This commit is contained in:
Hypatia May
2026-07-15 10:51:38 +08:00
parent e9f0c37745
commit ecf90ff382
38 changed files with 3181 additions and 120 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-session-query
Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect.
@@ -8,11 +8,24 @@ This is trusted context-wide infrastructure. It performs no caller authorization
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
## Filtering and extraction
`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`.
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
## Full-text seam
`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts).
## Configuration
@@ -20,4 +33,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis
|---|---:|---|
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
The package deliberately has no lineage/provenance traversal, extractor registry, search-provider registry, index synchronization, caller authorization, or model-facing tool. The SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md).

View File

@@ -1,4 +1,4 @@
/** Public configuration and typed failures for session-query. */
/** Public configuration and typed failures for session-query and search. */
import { HarnessError } from '@deepseek-ai/dsh-llm'
@@ -11,14 +11,21 @@ export interface Config {
readWindowMax?: number
}
/** Stable machine-routable failure taxonomy for exact session reads. */
/** Stable machine-routable failure taxonomy for session reads and search. */
export type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
| 'SESSION_QUERY_INDEX_FAILED'
| 'SESSION_QUERY_INVALID_CONFIG'
| 'SESSION_QUERY_INVALID_CURSOR'
| 'SESSION_QUERY_INVALID_FILTER'
| 'SESSION_QUERY_INVALID_LIMIT'
| 'SESSION_QUERY_INVALID_QUERY'
| 'SESSION_QUERY_INVALID_SURFACE'
| 'SESSION_QUERY_INVALID_WINDOW'
| 'SESSION_QUERY_PERSISTENCE_FAILED'
| 'SESSION_QUERY_SESSION_NOT_FOUND'
| 'SESSION_QUERY_STALE_CURSOR'
| 'SESSION_QUERY_SOURCE_CONFLICT'
/** Typed session-query failure whose `code` is one closed taxonomy member. */

View File

@@ -5,6 +5,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import type { SessionRecord } from './types.ts'
import { SessionQueryError } from './config.ts'
import { assertSessionHeadersCompatible } from './sources.ts'
/** Detached source selected for one exact read. */
export interface LogicalSession {
@@ -45,7 +46,7 @@ export class SessionCorpus {
}
for (const session of this._ctx.sessions.list()) {
const durable = records.get(session.id)
if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header)
if (durable !== undefined) assertSessionHeadersCompatible(session.header, durable.header)
records.set(session.id, {
header: structuredClone(session.header),
live: true,
@@ -80,7 +81,7 @@ export class SessionCorpus {
{ cause: error },
)
}
assertCompatibleHeaders(loaded.meta, listed)
assertSessionHeadersCompatible(loaded.meta, listed)
return {
header: structuredClone(loaded.meta),
events: loaded.events.map(event => structuredClone(event)),
@@ -107,22 +108,6 @@ function snapshotLive(session: Session): LogicalSession {
}
}
function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
if (
a.version !== b.version
|| a.id !== b.id
|| a.createdAt !== b.createdAt
|| a.cwd !== b.cwd
|| a.parentSession !== b.parentSession
|| a.seedLength !== b.seedLength
) {
throw new SessionQueryError(
`live and persisted headers conflict for session "${a.id}"`,
'SESSION_QUERY_SOURCE_CONFLICT',
)
}
}
function compareSessions(a: SessionRecord, b: SessionRecord): number {
return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id)
}

View File

@@ -0,0 +1,74 @@
/** Shared event metadata and semantic-document projection. */
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts'
import { SessionQueryError } from './config.ts'
import { extractSessionEventText } from './extraction.ts'
/**
* Project a raw log into lightweight surface-aware event records.
* @param sessionId - session that owns the log.
* @param events - complete contiguous raw event log.
* @returns one record per event in ascending seq order.
*/
export function buildSessionEventRecords(
sessionId: SessionId,
events: readonly SessionEvent[],
): SessionEventRecord[] {
const surfaceBySeq = classifySurface(events)
return events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
}))
}
/**
* Build first-party semantic documents for one complete raw event log.
* @param sessionId - session that owns the log.
* @param events - complete contiguous raw event log.
* @returns searchable documents in ascending seq order; structural events are omitted.
*/
export function buildSessionEventSearchDocuments(
sessionId: SessionId,
events: readonly SessionEvent[],
): SessionEventSearchDocument[] {
const surfaceBySeq = classifySurface(events)
const documents: SessionEventSearchDocument[] = []
for (const event of events) {
const text = extractSessionEventText(event)
if (text.length === 0) continue
documents.push({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
text,
})
}
return documents
}
function classifySurface(events: readonly SessionEvent[]): Map<number, SessionEventSurface> {
let folded: ReturnType<typeof foldSurface>
try {
folded = foldSurface(events)
} catch (error: unknown) {
throw new SessionQueryError(
/* v8 ignore next -- foldSurface throws Error instances */
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
'SESSION_QUERY_INVALID_SURFACE',
{ cause: error },
)
}
const result = new Map<number, SessionEventSurface>()
for (const node of folded.nodes) result.set(node.seq, 'current')
for (const replacement of folded.replacements) {
for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed')
}
return result
}

View File

@@ -0,0 +1,93 @@
/** First-party semantic text extraction for session-query consumers. */
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Extract searchable semantic text from one first-party session event.
*
* Structural boundaries, raw stream chunks, request envelopes, and unknown
* declaration-merged events contribute no text.
* @param event - event to inspect.
* @returns newline-joined semantic text, or an empty string when non-searchable.
*/
export function extractSessionEventText(event: SessionEvent): string {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'context/message':
case 'steering/message':
return contentText(event.data.content)
case 'prompt/blocked':
return joinText([contentText(event.data.content), event.data.reason])
case 'tool/call':
return joinText([event.data.name, event.data.arguments])
case 'tool/result':
return joinText([
contentText(event.data.content),
event.data.error?.name ?? '',
event.data.error?.code ?? '',
])
case 'todo/write':
return joinText(event.data.todos.flatMap(todo => [todo.status, todo.content]))
case 'turn/end':
return turnEndText(event.data.reason)
case 'turn/start':
case 'step/start':
case 'step/end':
case 'assistant/chunk':
case 'request/header':
case 'request/header-delta':
return ''
// SessionEventMap is merge-extensible. Unknown events remain
// non-searchable until a concrete first-party consumer defines semantics.
default:
return ''
}
}
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
switch (reason.kind) {
case 'error':
return joinText(['error', reason.message, reason.code ?? ''])
case 'aborted':
return joinText(['aborted', reason.reason ?? ''])
case 'rejected':
return joinText(['rejected', reason.reason])
case 'disposed':
case 'max-tokens':
case 'interrupted':
return reason.kind
case 'completed':
return ''
// TurnEndReasonMap is merge-extensible. Unknown outcomes stay out until
// their owner defines which detail is semantic rather than structural.
default:
return ''
}
}
type SessionContentBlock = SessionEvent<'user/message'>['data']['content'][number]
function contentText(content: readonly SessionContentBlock[]): string {
return joinText(content.flatMap(blockText))
}
function blockText(block: SessionContentBlock): string[] {
switch (block.type) {
case 'text':
case 'reasoning':
return [block.text]
case 'tool-call':
return [block.name, block.arguments]
case 'tool-result':
return block.content.flatMap(blockText)
// ContentBlockMap is merge-extensible. Unknown blocks do not become
// searchable merely because their payload happens to contain strings.
default:
return []
}
}
function joinText(parts: readonly string[]): string {
return parts.map(part => part.trim()).filter(Boolean).join('\n')
}

View File

@@ -0,0 +1,132 @@
/** Pure provider-independent predicates for logical sessions and event text. */
import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts'
import { SessionQueryError } from './config.ts'
/**
* Apply ANDed logical-session filters while preserving input order.
* @param records - detached logical-session records to inspect.
* @param filters - clauses whose list values are ORed within each clause.
* @returns records accepted by every clause.
*/
export function filterSessionResults<T extends SessionRecord>(
records: readonly T[],
filters: readonly SessionResultFilter[] = [],
): T[] {
const predicates = filters.map(sessionPredicate)
return records.filter(record => predicates.every(predicate => predicate(record)))
}
/**
* Apply ANDed event filters to extracted semantic documents.
* @param documents - semantic documents produced by {@link buildSessionEventSearchDocuments}.
* @param filters - metadata and literal-text predicates.
* @returns documents accepted by every clause, in input order.
*/
export function filterSessionEventDocuments<T extends SessionEventSearchDocument>(
documents: readonly T[],
filters: readonly SessionEventResultFilter[] = [],
): T[] {
const predicates = filters.map(eventPredicate)
return documents.filter(document => predicates.every(predicate => predicate(document)))
}
/**
* Compile a literal case-insensitive, whitespace-flexible semantic-text match.
* @param text - caller-provided literal text.
* @returns Unicode-aware regular expression safe from regex injection.
*/
export function compileSessionTextFilter(text: string): RegExp {
const trimmed = text.trim()
if (trimmed.length === 0) {
throw new SessionQueryError(
'session text filter must contain non-whitespace text',
'SESSION_QUERY_INVALID_FILTER',
)
}
const pattern = trimmed
.split(/\s+/u)
.map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
.join('\\s+')
return new RegExp(pattern, 'iu')
}
function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) => boolean {
switch (filter.kind) {
case 'id':
return record => filter.values.includes(record.header.id)
case 'cwd':
return record => filter.values.includes(record.header.cwd ?? null)
case 'created-at': {
const range = validateRange(filter.kind, filter)
return record => matchesRange(record.header.createdAt, range)
}
case 'parent':
return record => filter.values.includes(record.header.parentSession ?? null)
case 'availability':
assertAllowedValues(filter.kind, filter.values, ['live', 'persisted'])
return record => filter.values.some(value => value === 'live' ? record.live : record.persisted)
}
}
function eventPredicate(filter: SessionEventResultFilter): (document: SessionEventSearchDocument) => boolean {
switch (filter.kind) {
case 'seq': {
const range = validateRange(filter.kind, filter)
return document => matchesRange(document.seq, range)
}
case 'time': {
const range = validateRange(filter.kind, filter)
return document => matchesRange(document.time, range)
}
case 'type':
return document => filter.values.includes(document.type)
case 'surface':
assertAllowedValues(filter.kind, filter.values, ['current', 'shadowed', 'log-only'])
return document => filter.values.includes(document.surface)
case 'text': {
const pattern = compileSessionTextFilter(filter.text)
return document => pattern.test(document.text)
}
}
}
function assertAllowedValues(
name: string,
values: readonly string[],
allowed: readonly string[],
): void {
for (const value of values) {
if (!allowed.includes(value)) {
throw new SessionQueryError(
`session ${name} filter contains unknown value "${value}"`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
}
function validateRange(name: string, range: SessionResultRange): SessionResultRange {
if (range.from !== undefined && !Number.isFinite(range.from)) {
throw invalidRange(name, 'from must be finite')
}
if (range.to !== undefined && !Number.isFinite(range.to)) {
throw invalidRange(name, 'to must be finite')
}
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
throw invalidRange(name, 'from must be less than or equal to to')
}
return range
}
function matchesRange(value: number, range: SessionResultRange): boolean {
return (range.from === undefined || value >= range.from)
&& (range.to === undefined || value <= range.to)
}
function invalidRange(name: string, detail: string): SessionQueryError {
return new SessionQueryError(
`session ${name} filter ${detail}`,
'SESSION_QUERY_INVALID_FILTER',
)
}

View File

@@ -6,13 +6,20 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionEventResultFilter,
SessionEventReadRequest,
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchDocument,
SessionEventSearchRequest,
SessionEventWindow,
SessionRecord,
SessionSearchExecContext,
SessionSearchHit,
SessionSearchPage,
SessionSearchRequest,
} from './types.ts'
import {
SESSION_QUERY_READ_WINDOW_MAX,
@@ -20,17 +27,58 @@ import {
type Config,
} from './config.ts'
import { SessionCorpus } from './corpus.ts'
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
import { filterSessionEventDocuments } from './filters.ts'
export type * from './types.ts'
export type { Config, SessionQueryErrorCode } from './config.ts'
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
export { extractSessionEventText } from './extraction.ts'
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts'
export { assertSessionHeadersCompatible } from './sources.ts'
declare module 'cordis' {
interface Context {
sessionQuery: SessionQueryService
sessionSearch: SessionSearchService
}
}
/**
* Abstract full-text search service implemented by one concrete backend.
*
* The implementation owns source observation, reconciliation, cursor
* generations, ranking, and query execution as one lifecycle.
*/
export abstract class SessionSearchService extends Service {
constructor(ctx: Context) {
super(ctx, 'sessionSearch')
}
/**
* Search the live-preferred logical corpus and group by session.
* @param request - query text, metadata filters, page size, and cursor.
* @param exec - optional cancellation control.
* @returns session hits ranked by their strongest matching event.
*/
abstract searchSessions(
request: SessionSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionSearchHit>>
/**
* Search events within one live-preferred logical session.
* @param request - target session, query text, filters, page size, and cursor.
* @param exec - optional cancellation control.
* @returns matching event hits in deterministic relevance order.
*/
abstract searchEvents(
request: SessionEventSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>>
}
/** Live-preferred logical-corpus and exact-event read service. */
export class SessionQueryService extends Service {
static inject = ['sessions']
@@ -68,7 +116,22 @@ export class SessionQueryService extends Service {
*/
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
const loaded = await this._corpus.load(sessionId)
return eventRecords(sessionId, loaded.events)
return buildSessionEventRecords(sessionId, loaded.events)
}
/**
* Scan first-party semantic event documents with provider-independent filters.
* @param sessionId - live-preferred session id to scan.
* @param filters - ANDed metadata and literal-text predicates.
* @returns matching semantic documents in ascending seq order.
*/
async filterEvents(
sessionId: SessionId,
filters: readonly SessionEventResultFilter[],
): Promise<SessionEventSearchDocument[]> {
const loaded = await this._corpus.load(sessionId)
const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
return filterSessionEventDocuments(documents, filters)
}
/**
@@ -110,27 +173,4 @@ export class SessionQueryService extends Service {
}
}
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
let folded: ReturnType<typeof foldSurface>
try {
folded = foldSurface(events)
} catch (error: unknown) {
throw new SessionQueryError(
/* v8 ignore next -- foldSurface throws Error instances */
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
'SESSION_QUERY_INVALID_SURFACE',
{ cause: error },
)
}
const current = new Set(folded.nodes.map(node => node.seq))
const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
return events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
}))
}
export default SessionQueryService

View File

@@ -0,0 +1,25 @@
/** Shared immutable-header checks for logical session source observers. */
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from './config.ts'
/**
* Reject incompatible observations of one logical session source.
* @param a - first live, listed, or loaded header observation.
* @param b - second header observation expected to identify the same source.
*/
export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeader): void {
if (
a.version !== b.version
|| a.id !== b.id
|| a.createdAt !== b.createdAt
|| a.cwd !== b.cwd
|| a.parentSession !== b.parentSession
|| a.seedLength !== b.seedLength
) {
throw new SessionQueryError(
`session source headers conflict for session "${a.id}"`,
'SESSION_QUERY_SOURCE_CONFLICT',
)
}
}

View File

@@ -58,3 +58,99 @@ export interface SessionEventWindow {
/** Last seq included in `events`. */
endSeq: number
}
/** Inclusive numeric interval used by time and sequence filters. */
export interface SessionResultRange {
/** Inclusive lower bound. */
from?: number
/** Inclusive upper bound. */
to?: number
}
/** Source availability predicates understood by logical-session filters. */
export type SessionAvailability = 'live' | 'persisted'
/**
* One logical-session predicate. A filter array is ANDed; `values` within a
* clause are ORed.
*/
export type SessionResultFilter =
| { kind: 'id'; values: readonly SessionId[] }
| { kind: 'cwd'; values: readonly (string | null)[] }
| ({ kind: 'created-at' } & SessionResultRange)
| { kind: 'parent'; values: readonly (SessionId | null)[] }
| { kind: 'availability'; values: readonly SessionAvailability[] }
/**
* One event predicate. A filter array is ANDed; list-valued clauses are ORed.
* Text is a literal, case-insensitive, whitespace-flexible semantic-text scan.
*/
export type SessionEventResultFilter =
| ({ kind: 'seq' } & SessionResultRange)
| ({ kind: 'time' } & SessionResultRange)
| { kind: 'type'; values: readonly SessionEventType[] }
| { kind: 'surface'; values: readonly SessionEventSurface[] }
| { kind: 'text'; text: string }
/** Event predicates a full-text provider can apply before relevance ranking. */
export type SessionEventMetadataFilter = Exclude<SessionEventResultFilter, { kind: 'text' }>
/** Searchable semantic document derived from one session event. */
export interface SessionEventSearchDocument extends SessionEventRecord {
/** First-party semantic text used by scan filters and full-text indexes. */
text: string
}
/** One cursor-paginated result page. */
export interface SessionSearchPage<T> {
/** Results for this page in contract-defined order. */
items: readonly T[]
/** Opaque continuation cursor, absent on the final page. */
nextCursor?: string
}
/** Controls shared by cross-session and within-session search calls. */
export interface SessionSearchExecContext {
/** Abort caller waiting and interrupt provider work where supported. */
signal?: AbortSignal
}
/** Cross-session full-text search request. */
export interface SessionSearchRequest {
/** Full-text query interpreted as data, never executable FTS syntax. */
query: string
/** Logical-session predicates applied before event ranking. */
sessionFilters?: readonly SessionResultFilter[]
/** Event predicates applied before event ranking. */
eventFilters?: readonly SessionEventMetadataFilter[]
/** Maximum sessions in this page. */
limit?: number
/** Opaque cursor returned for the identical normalized request. */
cursor?: string
}
/** Within-session full-text search request. */
export interface SessionEventSearchRequest {
/** Session whose live-preferred logical log is searched. */
sessionId: SessionId
/** Full-text query interpreted as data, never executable FTS syntax. */
query: string
/** Event predicates applied before ranking. */
filters?: readonly SessionEventMetadataFilter[]
/** Maximum events in this page. */
limit?: number
/** Opaque cursor returned for the identical normalized request. */
cursor?: string
}
/** One event full-text search hit with a bounded plain-text excerpt. */
export interface SessionEventSearchHit extends SessionEventRecord {
/** Plain text excerpt selected around the match. */
snippet: string
}
/** One grouped cross-session hit, ranked by its strongest matching event. */
export interface SessionSearchHit extends SessionRecord {
/** Strongest matching event for this session. */
bestMatch: SessionEventSearchHit
}

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionQueryService, {
buildSessionEventRecords,
buildSessionEventSearchDocuments,
compileSessionTextFilter,
extractSessionEventText,
filterSessionEventDocuments,
filterSessionResults,
SessionSearchService,
type SessionEventSearchHit,
type SessionEventSearchRequest,
type SessionQueryErrorCode,
type SessionSearchExecContext,
type SessionSearchHit,
type SessionSearchPage,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
const id = SessionId('session')
function header(value: string, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra }
}
function expectCode(code: SessionQueryErrorCode): Error {
return expect.objectContaining({ code }) as Error
}
describe('session-query semantic extraction', () => {
it('extracts first-party message, tool, todo, and failure detail', () => {
const callId = CallId('call')
const messageContent: SessionEvent<'user/message'>['data']['content'] = [
{ type: 'text', text: ' visible ' },
{ type: 'reasoning', text: 'thought' },
{ type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' },
{
type: 'tool-result',
toolCallId: callId,
content: [{ type: 'text', text: 'nested' }],
isError: false,
},
{ type: 'future-content', payload: 'hidden' } as never,
]
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' },
{ type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } },
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } },
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' },
{ type: 'tool/result', seq: 7, time: 8, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' },
{ type: 'todo/write', seq: 8, time: 9, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } },
]
for (const event of events.slice(0, 4)) {
expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested')
}
expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy')
expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}')
expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS')
expect(extractSessionEventText(events[7]!)).toBe('')
expect(extractSessionEventText(events[8]!)).toBe('in_progress\nship search')
})
it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
[{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'],
[{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'],
[{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
[{ kind: 'aborted' }, 'aborted'],
[{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'],
[{ kind: 'disposed' }, 'disposed'],
[{ kind: 'max-tokens' }, 'max-tokens'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'completed' }, ''],
[{ kind: 'future-status' } as never, ''],
]
for (const [reason, text] of reasons) {
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text)
}
const structural: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } },
{ type: 'request/header-delta', seq: 5, time: 1, data: {} },
{ type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never,
]
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', ''])
})
})
describe('session-query document and filter helpers', () => {
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
]
it('classifies every event and omits non-semantic documents', () => {
expect(buildSessionEventRecords(id, events).map(record => record.surface))
.toEqual(['shadowed', 'log-only', 'current', 'log-only'])
const documents = buildSessionEventSearchDocuments(id, events)
expect(documents.map(document => [document.seq, document.text, document.surface])).toEqual([
[0, 'Hello\n(AI)+', 'shadowed'],
[2, 'replacement', 'current'],
[3, 'interrupted', 'log-only'],
])
})
it('applies every session clause with OR values and validates closed values', () => {
const parent = SessionId('parent')
const records = [
{ header: header('a', { cwd: '/a', parentSession: parent }), live: true, persisted: false, marker: 1 },
{ header: header('b', { createdAt: 20 }), live: false, persisted: true, marker: 2 },
]
expect(filterSessionResults(records, [
{ kind: 'id', values: [SessionId('a'), SessionId('x')] },
{ kind: 'cwd', values: ['/a', null] },
{ kind: 'created-at', from: 5, to: 15 },
{ kind: 'parent', values: [parent, null] },
{ kind: 'availability', values: ['live'] },
])).toEqual([records[0]])
expect(filterSessionResults(records, [{ kind: 'cwd', values: [null] }])).toEqual([records[1]])
expect(filterSessionResults(records, [{ kind: 'parent', values: [null] }])).toEqual([records[1]])
expect(filterSessionResults(records, [{ kind: 'availability', values: ['persisted'] }])).toEqual([records[1]])
expect(() => filterSessionResults(records, [{ kind: 'availability', values: ['remote' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('applies event metadata and safe literal text clauses', () => {
const documents = buildSessionEventSearchDocuments(id, events).map((document, marker) => ({ ...document, marker }))
expect(filterSessionEventDocuments(documents, [
{ kind: 'seq', from: 0, to: 1 },
{ kind: 'time', from: 9, to: 11 },
{ kind: 'type', values: ['user/message', 'tool/result'] },
{ kind: 'surface', values: ['shadowed'] },
{ kind: 'text', text: 'hello (ai)+' },
])).toEqual([documents[0]])
expect(compileSessionTextFilter('CAFÉ').test('café')).toBe(true)
expect(filterSessionEventDocuments(documents)).toEqual(documents)
expect(filterSessionEventDocuments(documents, [{ kind: 'surface', values: [] }])).toEqual([])
expect(() => filterSessionEventDocuments(documents, [{ kind: 'surface', values: ['future' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => compileSessionTextFilter(' \n ')).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('rejects malformed range filters and malformed surfaces', () => {
const documents = buildSessionEventSearchDocuments(id, events)
for (const filter of [
{ kind: 'seq', from: Number.NaN },
{ kind: 'seq', to: Number.POSITIVE_INFINITY },
{ kind: 'time', from: 2, to: 1 },
] as const) {
expect(() => filterSessionEventDocuments(documents, [filter]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
}
expect(() => filterSessionResults([], [{ kind: 'created-at', from: Number.NaN }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterSessionResults([{ header: header('x'), live: true, persisted: false }], [
{ kind: 'created-at', from: Number.NaN },
])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
const malformed: SessionEvent[] = [{
type: 'assistant/message',
seq: 0,
time: 1,
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] },
surfaceOp: { op: 'replace', start: 9, end: 9 },
}]
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('exposes the scan path on the concrete exact-read service', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService)
const session = ctx.sessions.create(id)
session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }]))
.resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }])
})
})
class TestSearchService extends SessionSearchService {
searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionSearchHit>> {
return Promise.resolve({ items: [] })
}
searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionEventSearchHit>> {
return Promise.resolve({ items: [] })
}
}
it('registers the abstract search seam under its independent ctx key', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(TestSearchService)
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
await fiber.dispose()
expect(ctx.sessionSearch).toBeUndefined()
})