feat(session-query): checkpoint build round 1

This commit is contained in:
Hypatia May
2026-07-10 16:51:19 +08:00
parent 42ebbfdf8f
commit aa1dc0e2c7
40 changed files with 3174 additions and 102 deletions

View File

@@ -0,0 +1,9 @@
# session-query/ — session retrieval capability family
Trusted read-model infrastructure over live and durable session logs. The interface package owns `ctx.sessionQuery`, logical-corpus resolution, filters, traces, text extractors, and the full-text provider contract. A search backend is a separate implementation package; a model tool or UI remains a separate consumer.
| Package | Role | ctx key |
|---|---|---|
| [`session-query/`](session-query/README.md) | Retrieval service and provider contract | `ctx.sessionQuery` |
The family is independent of the [compaction capability](../compact/README.md): it reads compaction provenance from the canonical session log but does not participate in compaction policy or execution. The provider-neutral decision is recorded in the [session-query RFC](../../docs/rfc/implemented/feature/2026-07-10-session-query-service.md); the first proposed backend is specified separately in the [SQLite provider RFC](../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).

View File

@@ -0,0 +1,46 @@
# @deepseek-ai/dsh-session-query
Provider-neutral session-history retrieval (`ctx.sessionQuery`). The service presents live `ctx.sessions` state and, when mounted, `ctx.sessionPersistence` state as one logical corpus. A matching id produces one record: live events win, while independent `live` and `persisted` flags report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT` instead of silently merging unrelated histories.
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.
## Reads and traces
- `listSessions()` returns cloned lightweight records in deterministic newest-first order.
- `listEvents(sessionId)` classifies each raw event as `current`, `shadowed`, or `log-only` using the shared `dsh-session` surface fold.
- `readEvent(request)` returns the cloned target and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax` (default 50).
- `traceSession(sessionId)` returns nearest-first parents, a known root or explicit unresolved parent id, and the complete deterministic descendant tree. A connected lineage cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(sessionId, seq)` returns direct provenance references and reverse references, direct shadows, the immediate replacer, and the transitive replacement chain toward the current surface node. Related nodes stay seq links; callers use `readEvent()` for content.
An installed persistence backend is optional and may mount or unmount dynamically. Cross-session operations fail with `SESSION_QUERY_PERSISTENCE_FAILED` while installed persistence is unreadable. A read targeting a known live session never depends on persistence health. Provider-side persisted rows are deactivated rather than deleted when persistence is absent.
## Filters
`filterSessionResults()` and `filterEventResults()` are pure generic transforms over records or richer hits. Each discriminated filter is serializable. Values within one filter are OR alternatives; filters in the supplied array are an AND chain. The functions preserve order and item identity and return a fresh array.
Session filters cover id, exact cwd, inclusive creation time, parent id/root, and live/persisted availability. Event filters cover inclusive seq/time, event type, and surface status. Search requests accept the same specs as pre-ranking filters. Applying the pure functions to a materialized provider page is a post-filter: it never fetches replacement hits to refill the page.
## Full-text providers
`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event.
The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search.
Persisted snapshots carry a SHA-256 fingerprint over canonical header/events plus the versions of relevant extractors. Reconciliation still loads and hashes canonical logs, but a provider replacement occurs only for a new or changed fingerprint; stale durable inventory entries are removed only while persistence is active and authoritative.
## Text extractors
Core extraction indexes semantic message text and reasoning, tool names/arguments/results, blocked prompts, context and steering, todos, and error/status detail. Stream chunks, request headers, and structural-only events contribute no document. Unknown event and content-block types contribute no text until their owner registers a versioned extractor with `registerEventTextExtractor()` or `registerContentTextExtractor()`.
Extractor registrations are unique per discriminant and effect-scoped. Their stable versions participate in fingerprints, so changing extraction semantics invalidates only sessions whose indexed source uses that extractor.
## Configuration
| Key | Default | Contract |
|---|---:|---|
| `searchProvider` | omitted | Explicit provider id; omission requires exactly one available provider. |
| `defaultLimit` | `20` | Search page size when the request omits `limit`. |
| `maxLimit` | `100` | Maximum accepted search page size; must be at least `defaultLimit`. |
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
The package ships no full-text backend and no model-facing tool. The proposed SQLite implementation is a later, independent phase described in the [SQLite provider RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).

View File

@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-session-query",
"description": "Provider-neutral live and persisted session retrieval service (ctx.sessionQuery)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
"optional": true
}
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,29 @@
/**
* Public configuration, defaults, and typed failures for session-query.
*
* @module @deepseek-ai/dsh-session-query/config
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Default page size for provider-backed search. */
export const SESSION_QUERY_DEFAULT_LIMIT = 20
/** Maximum page size accepted by provider-backed search. */
export const SESSION_QUERY_MAX_LIMIT = 100
/** Default maximum `before`/`after` raw-event window. */
export const SESSION_QUERY_READ_WINDOW_MAX = 50
/** Configuration for the provider-neutral session-query service. */
export interface Config {
/** Explicit provider id; omitted auto-selects exactly one usable provider. */
searchProvider?: string
/** Default search result page size. Defaults to 20. */
defaultLimit?: number
/** Maximum accepted search page size. Defaults to 100. */
maxLimit?: number
/** Maximum accepted raw read context on either side. Defaults to 50. */
readWindowMax?: number
}
/** Typed session-query failure with a stable machine-routable code. */
export class SessionQueryError extends HarnessError {}

View File

@@ -0,0 +1,221 @@
/** Live/persisted logical-corpus resolution for session-query. */
import type { Context } from 'cordis'
import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import type { SessionRecord } from './types.ts'
import type { LoadedSession } from './extraction.ts'
import { canonicalJson } from './extraction.ts'
import { SessionQueryError } from './config.ts'
interface PersistenceBinding {
token: symbol
service: SessionPersistence
headers: Map<SessionId, SessionHeader>
error?: unknown
refreshing: Promise<void> | undefined
}
/** Active persistence view used by provider reconciliation. */
export interface PersistenceView {
/** Canonical headers in deterministic creation order. */
headers: SessionHeader[]
/** Load one canonical persisted source. */
load(id: SessionId): Promise<LoadedSession>
}
/** Resolves one live-preferred corpus while containing optional persistence lifecycle. */
export class SessionCorpus {
private _persistence: PersistenceBinding | undefined
constructor(
private readonly _ctx: Context,
private readonly _onPersistenceChange: (active: boolean) => void,
) {
_ctx.effect(() => {
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
this._attachPersistence(childCtx, childCtx.sessionPersistence)
})
return () => void fiber.dispose()
}, 'sessionQuery.optionalPersistence')
}
/**
* List the complete logical corpus with live precedence and cloned headers.
* @returns logical records in deterministic newest-first order.
*/
async listSessions(): Promise<SessionRecord[]> {
const binding = await this._ensurePersistence()
const records = new Map<SessionId, SessionRecord>()
if (binding !== undefined) {
for (const header of binding.headers.values()) {
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
}
}
for (const session of this._ctx.sessions.list()) {
const persisted = binding?.headers.get(session.id)
if (persisted !== undefined) this._assertCompatibleHeaders(session.header, persisted)
records.set(session.id, {
header: structuredClone(session.header),
live: true,
persisted: persisted !== undefined,
})
}
return [...records.values()].sort(compareSessions)
}
/**
* Load one logical source, preferring a detached live snapshot.
* @param sessionId - session to resolve.
* @returns detached live-preferred metadata and events.
*/
async loadLogical(sessionId: SessionId): Promise<LoadedSession> {
const live = this._ctx.sessions.get(sessionId)
if (live !== undefined) return this.snapshotLive(live)
const binding = await this._ensurePersistence()
if (binding === undefined || !binding.headers.has(sessionId)) {
throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
return this._loadPersisted(binding, sessionId)
}
/**
* Return a detached live source with current availability flags.
* @param session - live session to snapshot.
* @returns detached metadata and events.
*/
snapshotLive(session: Session): LoadedSession {
const persistedHeader = this._persistence?.headers.get(session.id)
if (persistedHeader !== undefined) this._assertCompatibleHeaders(session.header, persistedHeader)
return {
record: {
header: structuredClone(session.header),
live: true,
persisted: persistedHeader !== undefined,
},
events: session.events.map(event => structuredClone(event)),
}
}
/**
* Get one live session without consulting persistence.
* @param sessionId - live id to resolve.
* @returns current store object, or undefined.
*/
getLive(sessionId: SessionId): Session | undefined {
return this._ctx.sessions.get(sessionId)
}
/**
* List live sessions in store order.
* @returns fresh array of current store objects.
*/
listLive(): Session[] {
return this._ctx.sessions.list()
}
/**
* Resolve an authoritative persisted view.
* @returns cloned headers and loader, or undefined while unmounted.
*/
async persistenceView(): Promise<PersistenceView | undefined> {
const binding = await this._ensurePersistence()
if (binding === undefined) return undefined
return {
headers: [...binding.headers.values()].map(header => structuredClone(header)).sort(compareHeadersAscending),
load: id => this._loadPersisted(binding, id),
}
}
private _attachPersistence(ctx: Context, service: SessionPersistence): void {
const binding: PersistenceBinding = {
token: Symbol('session-query-persistence'),
service,
headers: new Map(),
refreshing: undefined,
}
this._persistence = binding
this._onPersistenceChange(true)
void this._refreshPersistence(binding)
ctx.on('session/persisted', (header) => {
/* v8 ignore next -- a stale notification can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
binding.headers.set(header.id, structuredClone(header))
this._onPersistenceChange(true)
})
ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding')
}
private _detachPersistence(binding: PersistenceBinding): void {
/* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */
if (this._persistence?.token !== binding.token) return
this._persistence = undefined
this._onPersistenceChange(false)
}
private _refreshPersistence(binding: PersistenceBinding): Promise<void> {
if (binding.refreshing !== undefined) return binding.refreshing
const refresh = binding.service.list().then((headers) => {
/* v8 ignore next -- a list completion can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)]))
binding.error = undefined
this._onPersistenceChange(true)
}).catch((error: unknown) => {
/* v8 ignore next -- a failed list can race optional-service disposal */
if (this._persistence?.token !== binding.token) return
binding.error = error
}).finally(() => {
/* v8 ignore next -- a newer refresh may already own the slot */
if (binding.refreshing === refresh) binding.refreshing = undefined
})
binding.refreshing = refresh
return refresh
}
private async _ensurePersistence(): Promise<PersistenceBinding | undefined> {
const binding = this._persistence
if (binding === undefined) return undefined
await this._refreshPersistence(binding)
if (binding.error !== undefined) {
const cause = binding.error
throw new SessionQueryError(`session persistence listing failed: ${errorMessage(cause)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause })
}
return binding
}
private async _loadPersisted(binding: PersistenceBinding, sessionId: SessionId): Promise<LoadedSession> {
try {
const loaded = await binding.service.load(sessionId)
const listed = binding.headers.get(sessionId)
/* v8 ignore else -- every internal persisted load starts from a listed header */
if (listed !== undefined) this._assertCompatibleHeaders(loaded.meta, listed)
return {
record: { header: structuredClone(loaded.meta), live: false, persisted: true },
events: loaded.events.map(event => structuredClone(event)),
}
} catch (error: unknown) {
if (error instanceof SessionQueryError) throw error
throw new SessionQueryError(`failed to load session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error })
}
}
private _assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
if (canonicalJson(a) !== canonicalJson(b)) {
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)
}
function compareHeadersAscending(a: SessionHeader, b: SessionHeader): number {
return a.createdAt - b.createdAt || a.id.localeCompare(b.id)
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- persistence service contracts reject Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -0,0 +1,258 @@
/** Semantic text extraction and stable provider snapshot fingerprints. */
import { createHash } from 'node:crypto'
import type { Context } from 'cordis'
import type { ContentBlock, ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionEventType } from '@deepseek-ai/dsh-session'
import type {
SessionContentTextExtractor,
SessionEventTextExtractor,
SessionIndexDocument,
SessionIndexSnapshot,
SessionRecord,
} from './types.ts'
import { SessionQueryError } from './config.ts'
import { eventRecords } from './tracing.ts'
/** Canonical session source consumed by extraction and provider reconciliation. */
export interface LoadedSession {
/** Logical source metadata. */
record: SessionRecord
/** Detached canonical events. */
events: SessionEvent[]
}
interface StoredEventExtractor {
version: string
extract(event: SessionEvent): readonly string[]
}
interface StoredContentExtractor {
version: string
extract(block: ContentBlock): readonly string[]
}
/** Owns core/custom semantic extractors and builds versioned index snapshots. */
export class SessionTextExtractors {
private readonly _eventExtractors = new Map<SessionEventType, StoredEventExtractor>()
private readonly _contentExtractors = new Map<ContentBlockType, StoredContentExtractor>()
constructor(private readonly _onChange: () => void) {
this._installCoreExtractors()
}
/**
* Register one effect-scoped event extractor.
* @param ctx - contributing caller context.
* @param type - event discriminant.
* @param extractor - versioned semantic extractor.
* @returns disposer for the registration.
*/
registerEvent<K extends SessionEventType>(
ctx: Context,
type: K,
extractor: SessionEventTextExtractor<K>,
): () => void {
this._validateVersion(type, extractor.version)
if (this._eventExtractors.has(type)) {
throw new SessionQueryError(`session event text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR')
}
const stored: StoredEventExtractor = {
version: extractor.version,
extract: event => extractor.extract(event as SessionEvent<K>),
}
const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._eventExtractors.set(type, stored)
this._onChange()
yield () => {
this._eventExtractors.delete(type)
this._onChange()
}
}.bind(this), `sessionQuery.eventExtractor(${type})`)
return () => void dispose()
}
/**
* Register one effect-scoped content-block extractor.
* @param ctx - contributing caller context.
* @param type - content-block discriminant.
* @param extractor - versioned semantic extractor.
* @returns disposer for the registration.
*/
registerContent<K extends ContentBlockType>(
ctx: Context,
type: K,
extractor: SessionContentTextExtractor<K>,
): () => void {
this._validateVersion(type, extractor.version)
if (this._contentExtractors.has(type)) {
throw new SessionQueryError(`session content text extractor "${type}" is already registered`, 'SESSION_QUERY_DUPLICATE_EXTRACTOR')
}
const stored: StoredContentExtractor = {
version: extractor.version,
extract: block => extractor.extract(block as ContentBlockMap[K]),
}
const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._contentExtractors.set(type, stored)
this._onChange()
yield () => {
this._contentExtractors.delete(type)
this._onChange()
}
}.bind(this), `sessionQuery.contentExtractor(${type})`)
return () => void dispose()
}
/**
* Build one provider-neutral snapshot and SHA-256 source/version fingerprint.
* @param loaded - detached canonical source.
* @returns lightweight documents and stable fingerprint.
*/
buildSnapshot(loaded: LoadedSession): SessionIndexSnapshot {
const records = eventRecords(loaded.record.header.id, loaded.events)
const documents: SessionIndexDocument[] = []
const eventVersions = new Set<string>()
const blockVersions = new Set<string>()
for (const event of loaded.events) {
const extractor = this._eventExtractors.get(event.type)
if (extractor === undefined) continue
eventVersions.add(`${event.type}@${extractor.version}`)
collectBlockVersions(event.data, this._contentExtractors, blockVersions)
const text = normalizeText(extractor.extract(event))
if (text.length === 0) continue
// The event record array parallels the contiguous log.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
documents.push({ ...records[event.seq]!, text })
}
const fingerprint = createHash('sha256').update(canonicalJson({
header: loaded.record.header,
events: loaded.events,
eventExtractors: [...eventVersions].sort(),
contentExtractors: [...blockVersions].sort(),
})).digest('hex')
return {
session: cloneRecord(loaded.record),
fingerprint,
documents,
}
}
private _installCoreExtractors(): void {
this._contentExtractors.set('text', { version: '1', extract: block => [(block as ContentBlockMap['text']).text] })
this._contentExtractors.set('reasoning', { version: '1', extract: block => [(block as ContentBlockMap['reasoning']).text] })
this._contentExtractors.set('tool-call', {
version: '1',
extract: (block) => {
const call = block as ContentBlockMap['tool-call']
return [call.name, call.arguments]
},
})
this._contentExtractors.set('tool-result', {
version: '1',
extract: block => this._extractBlocks((block as ContentBlockMap['tool-result']).content),
})
for (const type of ['user/message', 'assistant/message', 'context/message', 'steering/message'] as const) {
this._eventExtractors.set(type, {
version: '1',
extract: event => this._extractBlocks((event as SessionEvent<typeof type>).data.content),
})
}
this._eventExtractors.set('prompt/blocked', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'prompt/blocked'>).data
return [...this._extractBlocks(data.content), data.reason]
},
})
this._eventExtractors.set('tool/call', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'tool/call'>).data
return [data.name, data.arguments]
},
})
this._eventExtractors.set('tool/result', {
version: '1',
extract: (event) => {
const data = (event as SessionEvent<'tool/result'>).data
return [...this._extractBlocks(data.content), data.error?.name ?? '', data.error?.code ?? '']
},
})
this._eventExtractors.set('todo/write', {
version: '1',
extract: event => (event as SessionEvent<'todo/write'>).data.todos.map(todo => `${todo.status} ${todo.content}`),
})
this._eventExtractors.set('turn/end', {
version: '1',
extract: (event) => {
const reason = (event as SessionEvent<'turn/end'>).data.reason
switch (reason.kind) {
case 'error': return ['error', reason.message, reason.code ?? '']
case 'aborted': return ['aborted', reason.reason ?? '']
case 'rejected': return ['rejected', reason.reason]
case 'disposed': return ['disposed']
case 'max-tokens': return ['max-tokens']
case 'interrupted': return ['interrupted']
case 'completed': return []
// TurnEndReasonMap is merge-extensible; unknown variants contribute no text.
/* v8 ignore next -- only an external declaration-merged reason can reach this fallback */
default: return []
}
},
})
}
private _extractBlocks(blocks: readonly ContentBlock[]): string[] {
const fragments: string[] = []
for (const block of blocks) {
const extractor = this._contentExtractors.get(block.type)
if (extractor !== undefined) fragments.push(...extractor.extract(block))
}
return fragments
}
private _validateVersion(type: string, version: string): void {
if (version.trim().length === 0) {
throw new SessionQueryError(`session-query extractor "${type}" requires a non-blank version`, 'SESSION_QUERY_INVALID_EXTRACTOR')
}
}
}
/**
* Encode canonical JSON with recursively sorted object keys.
* @param value - JSON-compatible source value.
* @returns deterministic JSON text.
*/
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
const object = value as Record<string, unknown>
return `{${Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(',')}}`
}
function normalizeText(fragments: readonly string[]): string {
return fragments.map(fragment => fragment.trim()).filter(Boolean).join('\n')
}
function collectBlockVersions(
value: unknown,
extractors: ReadonlyMap<ContentBlockType, StoredContentExtractor>,
versions: Set<string>,
): void {
if (Array.isArray(value)) {
for (const item of value) collectBlockVersions(item, extractors, versions)
return
}
if (value === null || typeof value !== 'object') return
const object = value as Record<string, unknown>
if (typeof object.type === 'string') {
const type = object.type as ContentBlockType
const extractor = extractors.get(type)
if (extractor !== undefined) versions.add(`${type}@${extractor.version}`)
}
for (const nested of Object.values(object)) collectBlockVersions(nested, extractors, versions)
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}

View File

@@ -0,0 +1,123 @@
/** Pure serializable session-query result filters. */
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {
SessionEventRecord,
SessionEventResultFilter,
SessionQueryRange,
SessionRecord,
SessionResultFilter,
} from './types.ts'
import { SessionQueryError } from './config.ts'
const AVAILABILITIES = ['live', 'persisted'] as const
const SURFACE_STATES = ['current', 'shadowed', 'log-only'] as const
/**
* Apply an ordered AND-chain of session filters while preserving item order
* and the concrete generic item type.
* @param results - session records or richer session search hits.
* @param filters - serializable filters applied in order.
* @returns a fresh filtered array.
*/
export function filterSessionResults<T extends SessionRecord>(
results: readonly T[],
filters: readonly SessionResultFilter[],
): T[] {
for (const filter of filters) validateSessionFilter(filter)
return results.filter(result => filters.every(filter => matchesSessionFilter(result, filter)))
}
/**
* Apply an ordered AND-chain of event filters while preserving item order and
* the concrete generic item type.
* @param results - event records or richer event search hits.
* @param filters - serializable filters applied in order.
* @returns a fresh filtered array.
*/
export function filterEventResults<T extends SessionEventRecord>(
results: readonly T[],
filters: readonly SessionEventResultFilter[],
): T[] {
for (const filter of filters) validateEventFilter(filter)
return results.filter(result => filters.every(filter => matchesEventFilter(result, filter)))
}
function matchesSessionFilter(record: SessionRecord, filter: SessionResultFilter): boolean {
switch (filter.kind) {
case 'id': return filter.values.includes(record.header.id)
case 'cwd': return filter.values.includes(record.header.cwd ?? null)
case 'created-at': return inRange(record.header.createdAt, filter.range)
case 'parent': return filter.values.includes(record.header.parentSession ?? null)
case 'availability': return filter.values.some(value => value === 'live' ? record.live : record.persisted)
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default: return assertNever(filter)
}
}
function matchesEventFilter(record: SessionEventRecord, filter: SessionEventResultFilter): boolean {
switch (filter.kind) {
case 'seq': return inRange(record.seq, filter.range)
case 'time': return inRange(record.time, filter.range)
case 'type': return filter.values.includes(record.type)
case 'surface': return filter.values.includes(record.surface)
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default: return assertNever(filter)
}
}
function validateSessionFilter(filter: SessionResultFilter): void {
switch (filter.kind) {
case 'id':
case 'cwd':
case 'parent':
return
case 'created-at':
validateRange('created-at', filter.range)
return
case 'availability':
for (const value of filter.values) {
if (!(AVAILABILITIES as readonly string[]).includes(value)) invalidFilter(`unknown availability "${value}"`)
}
return
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default:
assertNever(filter)
}
}
function validateEventFilter(filter: SessionEventResultFilter): void {
switch (filter.kind) {
case 'seq':
case 'time':
validateRange(filter.kind, filter.range)
return
case 'type':
return
case 'surface':
for (const value of filter.values) {
if (!(SURFACE_STATES as readonly string[]).includes(value)) invalidFilter(`unknown surface status "${value}"`)
}
return
/* v8 ignore next -- closed discriminated union exhaustiveness guard */
default:
assertNever(filter)
}
}
function validateRange(name: string, range: SessionQueryRange): void {
if (range.from !== undefined && !Number.isFinite(range.from)) invalidFilter(`${name}.from must be finite`)
if (range.to !== undefined && !Number.isFinite(range.to)) invalidFilter(`${name}.to must be finite`)
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
invalidFilter(`${name}.from must be <= ${name}.to`)
}
}
function invalidFilter(message: string): never {
throw new SessionQueryError(`session-query filter: ${message}`, 'SESSION_QUERY_INVALID_FILTER')
}
function inRange(value: number, range: SessionQueryRange): boolean {
return (range.from === undefined || value >= range.from)
&& (range.to === undefined || value <= range.to)
}

View File

@@ -0,0 +1,225 @@
/**
* Provider-neutral session-history retrieval over live and optionally
* persisted session logs. The public service composes logical-corpus reads,
* pure filters and tracing, semantic extraction, and provider coordination.
*
* @module @deepseek-ai/dsh-session-query
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ContentBlockType } from '@deepseek-ai/dsh-llm'
import type { SessionEventType, SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionContentTextExtractor,
SessionEventReadRequest,
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchRequest,
SessionEventTextExtractor,
SessionEventTrace,
SessionEventWindow,
SessionLineageTrace,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchRequest,
SessionQueryExecContext,
} from './types.ts'
import {
SESSION_QUERY_DEFAULT_LIMIT,
SESSION_QUERY_MAX_LIMIT,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
type Config,
} from './config.ts'
import { SessionTextExtractors } from './extraction.ts'
import { SessionCorpus } from './corpus.ts'
import { SessionProviderCoordinator } from './provider.ts'
import { eventRecords, traceEventLog, traceLineage } from './tracing.ts'
export type * from './types.ts'
export type { Config } from './config.ts'
export {
SESSION_QUERY_DEFAULT_LIMIT,
SESSION_QUERY_MAX_LIMIT,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
} from './config.ts'
export { filterEventResults, filterSessionResults } from './filters.ts'
declare module 'cordis' {
interface Context {
sessionQuery: SessionQueryService
}
}
/** Session-history retrieval and provider coordination service. */
export class SessionQueryService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
searchProvider: z.string(),
defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_DEFAULT_LIMIT),
maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_MAX_LIMIT),
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
})
private readonly _readWindowMax: number
private readonly _extractors: SessionTextExtractors
private readonly _providers: SessionProviderCoordinator
private readonly _corpus: SessionCorpus
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionQuery')
const defaultLimit = config.defaultLimit ?? SESSION_QUERY_DEFAULT_LIMIT
const maxLimit = config.maxLimit ?? SESSION_QUERY_MAX_LIMIT
this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
if (defaultLimit > maxLimit) {
throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG')
}
this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() })
this._providers = new SessionProviderCoordinator(ctx, {
...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {},
defaultLimit,
maxLimit,
}, () => this._corpus, this._extractors)
this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) })
}
/**
* List the complete logical corpus using live-preferred records.
* @returns deterministic newest-first cloned session records.
*/
listSessions(): Promise<SessionRecord[]> {
return this._corpus.listSessions()
}
/**
* List lightweight raw-log event records for one logical session.
* @param sessionId - live-preferred session id to read.
* @returns event records in ascending seq order.
*/
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
const loaded = await this._corpus.loadLogical(sessionId)
return eventRecords(sessionId, loaded.events)
}
/**
* Read one full event plus a bounded raw-log context window.
* @param request - target session/seq and context sizes.
* @returns cloned target and neighboring events.
*/
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
const before = this._readWindow('before', request.before)
const after = this._readWindow('after', request.after)
const loaded = await this._corpus.loadLogical(request.sessionId)
const target = loaded.events[request.seq]
if (target === undefined || target.seq !== request.seq) {
throw new SessionQueryError(`session "${request.sessionId}" has no event at seq ${request.seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND')
}
const startSeq = Math.max(0, request.seq - before)
const endSeq = Math.min(loaded.events.length - 1, request.seq + after)
return {
session: cloneRecord(loaded.record),
target: structuredClone(target),
events: loaded.events.slice(startSeq, endSeq + 1).map(event => structuredClone(event)),
startSeq,
endSeq,
}
}
/**
* Trace parent ancestry and the complete known descendant tree of a session.
* @param sessionId - logical session id to trace.
* @returns complete or explicitly partial lineage.
*/
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
return traceLineage(await this._corpus.listSessions(), sessionId)
}
/**
* Trace direct provenance and surface replacement relationships for any event.
* @param sessionId - logical session containing the target.
* @param seq - target event seq.
* @returns lightweight trace with related seq links.
*/
async traceEvent(sessionId: SessionId, seq: number): Promise<SessionEventTrace> {
return traceEventLog(sessionId, (await this._corpus.loadLogical(sessionId)).events, seq)
}
/**
* Register one full-text provider with effect-scoped disposal.
* @param provider - provider and synchronization implementation.
* @returns disposer that unregisters the provider.
*/
registerSearchProvider(provider: SessionSearchProvider): () => void {
return this._providers.register(this.ctx, provider)
}
/**
* Register semantic text extraction for one event type.
* @param type - declaration-merged event discriminant.
* @param extractor - stable version and typed extraction callback.
* @returns disposer that removes the extractor.
*/
registerEventTextExtractor<K extends SessionEventType>(
type: K,
extractor: SessionEventTextExtractor<K>,
): () => void {
return this._extractors.registerEvent(this.ctx, type, extractor)
}
/**
* Register semantic text extraction for one content block type.
* @param type - declaration-merged content-block discriminant.
* @param extractor - stable version and typed extraction callback.
* @returns disposer that removes the extractor.
*/
registerContentTextExtractor<K extends ContentBlockType>(
type: K,
extractor: SessionContentTextExtractor<K>,
): () => void {
return this._extractors.registerContent(this.ctx, type, extractor)
}
/**
* Search the complete logical corpus and rank one result per session.
* @param request - query, pre-ranking filters, and pagination.
* @param exec - optional cancellation context.
* @returns ranked provider page.
*/
searchSessions(
request: SessionSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
return this._providers.searchSessions(request, exec)
}
/**
* Search events within one logical session.
* @param request - target session, query, filters, and pagination.
* @param exec - optional cancellation context.
* @returns ranked provider page.
*/
searchEvents(
request: SessionEventSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
return this._providers.searchEvents(request, exec)
}
private _readWindow(name: 'before' | 'after', value: number | undefined): number {
if (value === undefined) return 0
if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) {
throw new SessionQueryError(`${name} must be an integer between 0 and ${this._readWindowMax}`, 'SESSION_QUERY_INVALID_WINDOW')
}
return value
}
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}
export default SessionQueryService

View File

@@ -0,0 +1,328 @@
/** Search-provider selection, synchronization, pagination, and cancellation. */
import type { Context } from 'cordis'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionTextExtractors } from './extraction.ts'
import type { PersistenceView, SessionCorpus } from './corpus.ts'
import type {
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchRequest,
SessionQueryExecContext,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchRequest,
} from './types.ts'
import type { Config } from './config.ts'
import { SessionQueryError } from './config.ts'
import { filterEventResults, filterSessionResults } from './filters.ts'
interface ProviderState {
provider: SessionSearchProvider
active: boolean
chain: Promise<void>
liveIds: Set<SessionId>
fullSync: Promise<void> | undefined
liveSync: Map<SessionId, Promise<void>>
}
type NormalizedSessionSearchRequest = SessionSearchRequest & { limit: number }
type NormalizedEventSearchRequest = SessionEventSearchRequest & { limit: number }
/** Coordinates one selected provider against live and persisted corpus layers. */
export class SessionProviderCoordinator {
private readonly _configuredProviderId: string | undefined
private readonly _defaultLimit: number
private readonly _maxLimit: number
private readonly _providers = new Map<string, ProviderState>()
constructor(
private readonly _ctx: Context,
config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>,
private readonly _corpus: () => SessionCorpus,
private readonly _extractors: SessionTextExtractors,
) {
this._configuredProviderId = config.searchProvider
this._defaultLimit = config.defaultLimit
this._maxLimit = config.maxLimit
_ctx.on('session/created', (session) => { this.invalidateLive(session.id) })
_ctx.on('session/event', (session) => { this.invalidateLive(session.id) })
_ctx.on('session/removed', (header) => { this.invalidateLive(header.id) })
}
/**
* Register one effect-scoped provider.
* @param ctx - contributing caller context.
* @param provider - provider implementation.
* @returns disposer for the registration.
*/
register(ctx: Context, provider: SessionSearchProvider): () => void {
if (this._providers.has(provider.id)) {
throw new SessionQueryError(`a session-query provider with id "${provider.id}" is already registered`, 'SESSION_QUERY_DUPLICATE_PROVIDER')
}
const state: ProviderState = {
provider,
active: true,
chain: Promise.resolve(),
liveIds: new Set(),
fullSync: undefined,
liveSync: new Map(),
}
const dispose = ctx.effect(function* (this: SessionProviderCoordinator) {
this._providers.set(provider.id, state)
void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => {
this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`)
})
yield () => {
state.active = false
this._providers.delete(provider.id)
}
}.bind(this), 'sessionQuery.registerSearchProvider()')
return () => void dispose()
}
/**
* Search and group the complete logical corpus.
* @param request - normalized provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked session page.
*/
async searchSessions(
request: SessionSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeSessionSearch(request)
await waitFor(this._syncAll(state), exec?.signal)
const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal)
return this._validateSearchPage(state, result, normalized.limit)
}
/**
* Search events within one logical session.
* @param request - target and provider-neutral request input.
* @param exec - optional cancellation controls.
* @returns ranked event page.
*/
async searchEvents(
request: SessionEventSearchRequest,
exec?: SessionQueryExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
const state = this._resolveProvider()
const normalized = this._normalizeEventSearch(request)
const live = this._corpus().getLive(request.sessionId)
if (live !== undefined) {
await waitFor(this._syncLive(state, live), exec?.signal)
} else {
const persistence = await this._corpus().persistenceView()
if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) {
throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
await waitFor(this._syncAll(state), exec?.signal)
}
const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal)
return this._validateSearchPage(state, result, normalized.limit)
}
/**
* Invalidate provider synchronization after one live source change.
* @param sessionId - changed live session.
*/
invalidateLive(sessionId: SessionId): void {
for (const state of this._providers.values()) {
state.fullSync = undefined
state.liveSync.delete(sessionId)
}
}
/** Invalidate all source/extractor-derived provider snapshots. */
invalidateAll(): void {
for (const state of this._providers.values()) {
state.fullSync = undefined
state.liveSync.clear()
}
}
/**
* React to persistence mount, inventory change, or unmount.
* @param active - whether canonical persistence remains mounted.
*/
persistenceChanged(active: boolean): void {
for (const state of this._providers.values()) state.fullSync = undefined
if (active) return
for (const state of this._providers.values()) {
void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => {
this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`)
})
}
}
private _syncAll(state: ProviderState): Promise<void> {
if (state.fullSync !== undefined) return state.fullSync
const promise = this._enqueue(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
if (!state.active) return
const persistence = await this._corpus().persistenceView()
if (persistence === undefined) {
await state.provider.setPersistedActive(false)
} else {
await this._syncPersisted(state, persistence)
}
await this._replaceLiveCorpus(state, this._corpus().listLive())
})
state.fullSync = promise
void promise.finally(() => {
/* v8 ignore next -- a newer invalidation may already own the sync slot */
if (state.fullSync === promise) state.fullSync = undefined
}).catch(() => undefined)
return promise
}
private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise<void> {
await state.provider.setPersistedActive(false)
const inventory = new Map((await state.provider.persistedInventory()).map(entry => [entry.sessionId, entry.fingerprint]))
for (const header of persistence.headers) {
const snapshot = this._extractors.buildSnapshot(await persistence.load(header.id))
if (inventory.get(header.id) !== snapshot.fingerprint) await state.provider.replacePersisted(snapshot)
inventory.delete(header.id)
}
for (const staleId of inventory.keys()) await state.provider.removePersisted(staleId)
await state.provider.setPersistedActive(true)
}
private async _replaceLiveCorpus(state: ProviderState, sessions: readonly Session[]): Promise<void> {
const liveIds = new Set(sessions.map(session => session.id))
for (const staleId of state.liveIds) {
if (!liveIds.has(staleId)) await state.provider.removeLive(staleId)
}
for (const session of sessions) {
await state.provider.replaceLive(this._snapshotLive(session))
}
state.liveIds = liveIds
}
private _syncLive(state: ProviderState, session: Session): Promise<void> {
const existing = state.liveSync.get(session.id)
if (existing !== undefined) return existing
const snapshot = this._snapshotLive(session)
const promise = this._enqueue(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
if (!state.active) return
await state.provider.replaceLive(snapshot)
state.liveIds.add(session.id)
})
state.liveSync.set(session.id, promise)
void promise.finally(() => {
/* v8 ignore next -- a newer invalidation may already own the target slot */
if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id)
}).catch(() => undefined)
return promise
}
private _snapshotLive(session: Session): ReturnType<SessionTextExtractors['buildSnapshot']> {
return this._extractors.buildSnapshot(this._corpus().snapshotLive(session))
}
private _enqueue(state: ProviderState, operation: () => Promise<void>): Promise<void> {
const next = state.chain.then(operation, operation)
state.chain = next.then(() => undefined, () => undefined)
return next.catch((error: unknown) => {
/* v8 ignore next -- service-created typed synchronization errors pass through unchanged */
if (error instanceof SessionQueryError) throw error
throw new SessionQueryError(`session-query provider "${state.provider.id}" synchronization failed: ${errorMessage(error)}`, 'SESSION_QUERY_INDEX_FAILED', { cause: error })
})
}
private _resolveProvider(): ProviderState {
if (this._configuredProviderId !== undefined) {
const state = this._providers.get(this._configuredProviderId)
if (state === undefined) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is not registered`, 'SESSION_QUERY_PROVIDER_CONFIGURED_MISSING')
}
if (!state.provider.status().available) {
throw new SessionQueryError(`configured session-query provider "${this._configuredProviderId}" is unavailable`, 'SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE')
}
return state
}
const usable = [...this._providers.values()].filter(state => state.provider.status().available)
const [single] = usable
if (single === undefined) {
throw new SessionQueryError('no usable session-query provider is registered', 'SESSION_QUERY_PROVIDER_UNAVAILABLE')
}
if (usable.length > 1) {
throw new SessionQueryError(`multiple usable session-query providers are registered (${usable.map(state => state.provider.id).join(', ')}); configure one explicitly`, 'SESSION_QUERY_PROVIDER_AMBIGUOUS')
}
return single
}
private _normalizeSessionSearch(request: SessionSearchRequest): NormalizedSessionSearchRequest {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterSessionResults<SessionRecord>([], request.sessionFilters ?? [])
filterEventResults<SessionEventRecord>([], request.eventFilters ?? [])
return { ...request, query, limit }
}
private _normalizeEventSearch(request: SessionEventSearchRequest): NormalizedEventSearchRequest {
const query = this._queryText(request.query)
const limit = this._limitValue(request.limit)
filterEventResults<SessionEventRecord>([], request.filters ?? [])
return { ...request, query, limit }
}
private _queryText(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
throw new SessionQueryError('session-query search text must not be blank', 'SESSION_QUERY_INVALID_QUERY')
}
return normalized
}
private _limitValue(limit: number | undefined): number {
const value = limit ?? this._defaultLimit
if (!Number.isInteger(value) || value < 1 || value > this._maxLimit) {
throw new SessionQueryError(`session-query limit must be an integer between 1 and ${this._maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT')
}
return value
}
private _validateSearchPage<T>(state: ProviderState, page: SessionSearchPage<T>, limit: number): SessionSearchPage<T> {
if (page.providerId !== state.provider.id) {
throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR')
}
return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) }
}
}
function waitFor<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return work
if (signal.aborted) return Promise.reject(aborted())
return new Promise<T>((resolve, reject) => {
const onAbort = () => { reject(aborted()) }
signal.addEventListener('abort', onAbort, { once: true })
work.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
/* v8 ignore next -- Promise contracts reject with Error; retain a typed boundary for third-party providers */
reject(error instanceof Error
? error
: new SessionQueryError('session-query operation failed with a non-Error rejection', 'SESSION_QUERY_PROVIDER_ERROR', { cause: error }))
},
)
})
}
function aborted(): SessionQueryError {
return new SessionQueryError('session-query operation aborted', 'SESSION_QUERY_ABORTED')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- provider update contracts reject Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -0,0 +1,158 @@
/** Session lineage and event surface/provenance tracing. */
import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionEventRecord,
SessionEventTrace,
SessionLineageNode,
SessionLineageTrace,
SessionRecord,
} from './types.ts'
import { SessionQueryError } from './config.ts'
/**
* Classify raw events against the canonical surface fold.
* @param sessionId - owner of the event log.
* @param events - detached raw log.
* @returns lightweight records in seq order.
*/
export function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
const fold = safeFold(events)
const current = new Set(fold.nodes.map(node => node.seq))
const shadowed = new Set(fold.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',
}))
}
/**
* Build one event trace from a validated logical event log.
* @param sessionId - owner of the event log.
* @param events - detached raw log.
* @param seq - target event seq.
* @returns direct provenance and replacement relationships.
*/
export function traceEventLog(sessionId: SessionId, events: readonly SessionEvent[], seq: number): SessionEventTrace {
const target = events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(`session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND')
}
const records = eventRecords(sessionId, events)
const fold = safeFold(events)
const shadowedBy = new Map<number, number>()
const shadows = new Map<number, number[]>()
for (const replacement of fold.replacements) {
shadows.set(replacement.seq, [...replacement.shadowedSeqs])
for (const shadowed of replacement.shadowedSeqs) shadowedBy.set(shadowed, replacement.seq)
}
const references: number[] = []
const referencedBy: number[] = []
for (const event of events) {
if (!isSurfaceEvent(event)) continue
for (const source of event.sourceEventSeqs ?? []) {
if (event.seq === seq) references.push(source)
if (source === seq) referencedBy.push(event.seq)
}
}
const replacementChain: number[] = []
let replacement = shadowedBy.get(seq)
while (replacement !== undefined) {
replacementChain.push(replacement)
replacement = shadowedBy.get(replacement)
}
const immediate = shadowedBy.get(seq)
// seq was checked against the contiguous event log, so its parallel record exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const targetRecord = records[seq]!
return {
target: { ...targetRecord },
...immediate !== undefined ? { shadowedBy: immediate } : {},
replacementChain,
shadows: shadows.get(seq) ?? [],
references,
referencedBy,
}
}
/**
* Trace ancestry and descendants within one materialized logical corpus.
* @param records - complete visible logical corpus.
* @param sessionId - target session id.
* @returns complete known lineage or explicit unresolved parent.
*/
export function traceLineage(records: readonly SessionRecord[], sessionId: SessionId): SessionLineageTrace {
const byId = new Map(records.map(record => [record.header.id, record]))
const target = byId.get(sessionId)
if (target === undefined) {
throw new SessionQueryError(`session "${sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
}
const parents: SessionRecord[] = []
const ancestrySeen = new Set<SessionId>([sessionId])
let unresolvedParentId: SessionId | undefined
let parentId = target.header.parentSession
while (parentId !== undefined) {
if (ancestrySeen.has(parentId)) lineageCycle(parentId)
ancestrySeen.add(parentId)
const parent = byId.get(parentId)
if (parent === undefined) {
unresolvedParentId = parentId
break
}
parents.push(parent)
parentId = parent.header.parentSession
}
const childrenByParent = new Map<SessionId, SessionRecord[]>()
for (const record of records) {
const parent = record.header.parentSession
if (parent === undefined) continue
const children = childrenByParent.get(parent) ?? []
children.push(record)
childrenByParent.set(parent, children)
}
for (const children of childrenByParent.values()) children.sort(compareSessionsAscending)
const buildChildren = (id: SessionId): SessionLineageNode[] => (childrenByParent.get(id) ?? []).map(child => ({
session: cloneRecord(child),
children: buildChildren(child.header.id),
}))
return {
target: cloneRecord(target),
parents: parents.map(cloneRecord),
...unresolvedParentId !== undefined
? { unresolvedParentId }
: { root: cloneRecord(parents.at(-1) ?? target) },
children: buildChildren(sessionId),
}
}
function safeFold(events: readonly SessionEvent[]): ReturnType<typeof foldSurface> {
try {
return foldSurface(events)
} catch (error: unknown) {
throw new SessionQueryError(`invalid session surface: ${errorMessage(error)}`, 'SESSION_QUERY_INVALID_SURFACE', { cause: error })
}
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}
function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number {
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
}
function lineageCycle(id: SessionId): never {
throw new SessionQueryError(`session lineage contains a cycle at "${id}"`, 'SESSION_QUERY_INVALID_LINEAGE')
}
function errorMessage(error: unknown): string {
/* v8 ignore next -- foldSurface throws Error instances */
return error instanceof Error ? error.message : 'unknown error'
}

View File

@@ -0,0 +1,292 @@
/**
* Public vocabulary for the session-query retrieval service: lightweight
* records, composable filters, traces, search requests/results, extractor
* registrations, and the provider synchronization contract.
*
* @module @deepseek-ai/dsh-session-query/types
*/
import type { ContentBlockMap, ContentBlockType } from '@deepseek-ai/dsh-llm'
import type {
SessionEvent,
SessionEventType,
SessionHeader,
SessionId,
} from '@deepseek-ai/dsh-session'
/** Whether an event is on the current surface, was replaced, or is log-only. */
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
/** Lightweight identity and availability for one logical session. */
export interface SessionRecord {
/** Cloned immutable session header selected from the live-preferred corpus. */
header: SessionHeader
/** Whether the id currently exists in `ctx.sessions`. */
live: boolean
/** Whether the active persistence backend currently materializes the id. */
persisted: boolean
}
/** Lightweight metadata for one event within a logical session. */
export interface SessionEventRecord {
/** Session that owns the event. */
sessionId: SessionId
/** Monotonic event seq within the session. */
seq: number
/** Discriminant of the session event. */
type: SessionEventType
/** Event timestamp in Unix epoch milliseconds. */
time: number
/** Event placement in the folded session surface. */
surface: SessionEventSurface
}
/** Inclusive numeric range used by result and search filters. */
export interface SessionQueryRange {
/** Inclusive lower bound. */
from?: number
/** Inclusive upper bound. */
to?: number
}
/** Serializable filter applied to session records. */
export type SessionResultFilter =
| { kind: 'id'; values: readonly SessionId[] }
| { kind: 'cwd'; values: readonly (string | null)[] }
| { kind: 'created-at'; range: SessionQueryRange }
| { kind: 'parent'; values: readonly (SessionId | null)[] }
| { kind: 'availability'; values: readonly ('live' | 'persisted')[] }
/** Serializable filter applied to event records. */
export type SessionEventResultFilter =
| { kind: 'seq'; range: SessionQueryRange }
| { kind: 'time'; range: SessionQueryRange }
| { kind: 'type'; values: readonly SessionEventType[] }
| { kind: 'surface'; values: readonly SessionEventSurface[] }
/** Caller cancellation threaded through synchronization and provider search. */
export interface SessionQueryExecContext {
/** Abort signal for waiting and provider-owned query work. */
readonly signal?: AbortSignal
}
/** Cheap local usability status returned by a search provider. */
export type SessionSearchProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' }
/** Common pagination fields accepted by both search scopes. */
export interface SessionSearchPageRequest {
/** Maximum number of hits on this page. */
limit?: number
/** Opaque cursor returned by the same provider/request. */
cursor?: string
}
/** Cross-session full-text request. */
export interface SessionSearchRequest extends SessionSearchPageRequest {
/** Plain text query interpreted by the selected provider. */
query: string
/** Session metadata filters applied before event ranking/grouping. */
sessionFilters?: readonly SessionResultFilter[]
/** Event metadata filters applied before best-event grouping. */
eventFilters?: readonly SessionEventResultFilter[]
}
/** Full-text request scoped to one session's events. */
export interface SessionEventSearchRequest extends SessionSearchPageRequest {
/** Session whose events form the search corpus. */
sessionId: SessionId
/** Plain text query interpreted by the selected provider. */
query: string
/** Event metadata filters applied before ranking. */
filters?: readonly SessionEventResultFilter[]
}
/** One lightweight event search hit with provider-produced evidence text. */
export interface SessionEventSearchHit extends SessionEventRecord {
/** Plain-text excerpt explaining the match. */
snippet: string
}
/** One session-ranked search hit and its strongest matching event. */
export interface SessionSearchHit extends SessionRecord {
/** Strongest matching event used as the session's ranking evidence. */
bestMatch: SessionEventSearchHit
}
/** One provider-owned page of search results. */
export interface SessionSearchPage<T> {
/** Stable id of the provider that produced this page. */
providerId: string
/** Ranked hits in deterministic provider order. */
items: readonly T[]
/** Opaque next-page cursor, absent when the result is exhausted. */
nextCursor?: string
}
/** Request for one event plus raw neighboring log context. */
export interface SessionEventReadRequest {
/** Session that owns the target event. */
sessionId: SessionId
/** Target event seq. */
seq: number
/** Number of preceding raw events to include. */
before?: number
/** Number of following raw events to include. */
after?: number
}
/** Full target event and a bounded raw-log window. */
export interface SessionEventWindow {
/** Logical session metadata at read time. */
session: SessionRecord
/** Full cloned target event. */
target: SessionEvent
/** Full cloned events from `startSeq` through `endSeq`. */
events: SessionEvent[]
/** First seq included in `events`. */
startSeq: number
/** Last seq included in `events`. */
endSeq: number
}
/** Recursive child node in a session lineage trace. */
export interface SessionLineageNode {
/** Session represented by this lineage node. */
session: SessionRecord
/** Direct children in deterministic creation order. */
children: SessionLineageNode[]
}
/** Complete known lineage around one session. */
export interface SessionLineageTrace {
/** Session that was traced. */
target: SessionRecord
/** Known parents from immediate parent outward. */
parents: SessionRecord[]
/** Root when the complete parent chain is available. */
root?: SessionRecord
/** First parent id outside the visible corpus, when the trace is partial. */
unresolvedParentId?: SessionId
/** Complete known descendant forest rooted at the target's direct children. */
children: SessionLineageNode[]
}
/** Surface and provenance relationships for one event. */
export interface SessionEventTrace {
/** Lightweight target record. */
target: SessionEventRecord
/** Immediate replacement event that shadowed the target. */
shadowedBy?: number
/** Replacement seqs from the target toward the current descendant. */
replacementChain: number[]
/** Surface nodes directly shadowed by the target replacement event. */
shadows: number[]
/** Direct provenance sources from `sourceEventSeqs`. */
references: number[]
/** Events that directly name the target in `sourceEventSeqs`. */
referencedBy: number[]
}
/** Typed extractor for one declaration-merged session event type. */
export interface SessionEventTextExtractor<K extends SessionEventType = SessionEventType> {
/** Stable cache-invalidation version chosen by the extractor owner. */
version: string
/**
* Extract semantic searchable fragments from one event.
* @param event - event narrowed to the registered type.
* @returns plain-text fragments; blanks are discarded by the service.
*/
extract(event: SessionEvent<K>): readonly string[]
}
/** Typed extractor for one declaration-merged content block type. */
export interface SessionContentTextExtractor<K extends ContentBlockType = ContentBlockType> {
/** Stable cache-invalidation version chosen by the extractor owner. */
version: string
/**
* Extract semantic searchable fragments from one content block.
* @param block - block narrowed to the registered type.
* @returns plain-text fragments; blanks are discarded by the service.
*/
extract(block: ContentBlockMap[K]): readonly string[]
}
/** One provider-neutral event document produced by registered extractors. */
export interface SessionIndexDocument extends SessionEventRecord {
/** Normalized newline-joined text indexed by a search provider. */
text: string
}
/** One complete index layer for a live session or persisted checkpoint. */
export interface SessionIndexSnapshot {
/** Layer metadata and live/persisted availability exposed in results. */
session: SessionRecord
/** Stable SHA-256 identity of canonical source data and extractor versions. */
fingerprint: string
/** Searchable event documents in seq order. */
documents: readonly SessionIndexDocument[]
}
/** Durable provider inventory entry used to reuse unchanged persisted rows. */
export interface SessionPersistedIndexEntry {
/** Persisted session id. */
sessionId: SessionId
/** Last indexed source/extractor fingerprint. */
fingerprint: string
}
/** Search and synchronization backend registered into `ctx.sessionQuery`. */
export interface SessionSearchProvider {
/** Stable provider id, unique within the query service. */
readonly id: string
/**
* Return cheap local usability without performing index or search I/O.
* @returns whether the provider can be selected.
*/
status(): SessionSearchProviderStatus
/**
* Read reusable persisted-layer fingerprints from derived storage.
* @returns durable inventory entries.
*/
persistedInventory(): Promise<readonly SessionPersistedIndexEntry[]>
/**
* Hide or expose reconciled persisted rows without deleting their cache.
* @param active - whether canonical persistence is mounted and reconciled.
*/
setPersistedActive(active: boolean): Promise<void>
/**
* Atomically replace one persisted session's derived documents.
* @param snapshot - canonical persisted checkpoint and fingerprint.
*/
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>
/**
* Delete one durable derived entry after canonical reconciliation proves it absent.
* @param sessionId - persisted id to remove.
*/
removePersisted(sessionId: SessionId): Promise<void>
/**
* Replace one connection-local live override.
* @param snapshot - current live snapshot and availability.
*/
replaceLive(snapshot: SessionIndexSnapshot): Promise<void>
/**
* Drop one live override, revealing its active persisted base when present.
* @param sessionId - live id to remove.
*/
removeLive(sessionId: SessionId): Promise<void>
/**
* Search and group the complete logical corpus by session.
* @param request - query, pre-ranking filters, and pagination.
* @param exec - optional cancellation context.
* @returns one ranked session page.
*/
searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionSearchHit>>
/**
* Search events within one logical session.
* @param request - target session, query, filters, and pagination.
* @param exec - optional cancellation context.
* @returns one ranked event page.
*/
searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionEventSearchHit>>
}

View File

@@ -0,0 +1,704 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
SessionQueryError,
filterEventResults,
filterSessionResults,
} from '@deepseek-ai/dsh-session-query'
import type {
SessionEventSearchHit,
SessionEventSearchRequest,
SessionIndexSnapshot,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchProviderStatus,
SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
declare module '@deepseek-ai/dsh-llm' {
interface ContentBlockMap {
'test/text': { type: 'test/text'; value: string }
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/note': { note: string }
}
}
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
}
function eventLog(text = 'hello'): SessionEvent[] {
return [{
type: 'user/message',
seq: 0,
time: 10,
data: { content: [{ type: 'text', text }], source: { kind: 'user' } },
surfaceOp: 'append',
}]
}
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listFailure: unknown
static loadFailure: unknown
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.listFailure = undefined
this.loadFailure = undefined
}
create(meta: SessionHeader): Promise<void> {
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
}
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing test session')
entry.events.push(...structuredClone(events))
return Promise.resolve()
}
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure))
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
return Promise.resolve(structuredClone(entry))
}
list(): Promise<SessionHeader[]> {
if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure))
return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)))
}
}
class FakeProvider implements SessionSearchProvider {
readonly id: string
statusValue: SessionSearchProviderStatus = { available: true }
persisted = new Map<SessionIdType, SessionIndexSnapshot>()
live = new Map<SessionIdType, SessionIndexSnapshot>()
activeHistory: boolean[] = []
removedPersisted: SessionIdType[] = []
removedLive: SessionIdType[] = []
sessionRequests: SessionSearchRequest[] = []
eventRequests: SessionEventSearchRequest[] = []
failNextLive = false
failNextPersisted = false
failNextActive = false
sessionPage: SessionSearchPage<SessionSearchHit>
eventPage: SessionSearchPage<SessionEventSearchHit>
constructor(id = 'fake') {
this.id = id
this.sessionPage = { providerId: id, items: [] }
this.eventPage = { providerId: id, items: [] }
}
status(): SessionSearchProviderStatus {
return this.statusValue
}
persistedInventory(): Promise<readonly { sessionId: SessionIdType; fingerprint: string }[]> {
return Promise.resolve([...this.persisted.values()].map(snapshot => ({
sessionId: snapshot.session.header.id,
fingerprint: snapshot.fingerprint,
})))
}
setPersistedActive(active: boolean): Promise<void> {
if (this.failNextActive) {
this.failNextActive = false
return Promise.reject(new Error('activation failed'))
}
this.activeHistory.push(active)
return Promise.resolve()
}
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void> {
if (this.failNextPersisted) {
this.failNextPersisted = false
return Promise.reject(new Error('persisted index failed'))
}
this.persisted.set(snapshot.session.header.id, structuredClone(snapshot))
return Promise.resolve()
}
removePersisted(sessionId: SessionIdType): Promise<void> {
this.removedPersisted.push(sessionId)
this.persisted.delete(sessionId)
return Promise.resolve()
}
replaceLive(snapshot: SessionIndexSnapshot): Promise<void> {
if (this.failNextLive) {
this.failNextLive = false
return Promise.reject(new Error('live index failed'))
}
this.live.set(snapshot.session.header.id, structuredClone(snapshot))
return Promise.resolve()
}
removeLive(sessionId: SessionIdType): Promise<void> {
this.removedLive.push(sessionId)
this.live.delete(sessionId)
return Promise.resolve()
}
searchSessions(request: SessionSearchRequest): Promise<SessionSearchPage<SessionSearchHit>> {
this.sessionRequests.push(structuredClone(request))
return Promise.resolve(structuredClone(this.sessionPage))
}
searchEvents(request: SessionEventSearchRequest): Promise<SessionSearchPage<SessionEventSearchHit>> {
this.eventRequests.push(structuredClone(request))
return Promise.resolve(structuredClone(this.eventPage))
}
}
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService, config)
return ctx
}
function expectCode(code: string): Error {
return expect.objectContaining({ code }) as Error
}
function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value))
}
describe('pure result filters', () => {
it('chains session filters as AND while values within one filter are OR', () => {
const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false }
const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true }
const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true }
const input = [child, root, both]
const output = filterSessionResults(input, [
{ kind: 'cwd', values: ['/a', '/b'] },
{ kind: 'created-at', range: { from: 2, to: 3 } },
{ kind: 'parent', values: [root.header.id] },
{ kind: 'availability', values: ['live', 'persisted'] },
{ kind: 'id', values: [child.header.id, both.header.id] },
])
expect(output).toEqual([child, both])
expect(output[0]).toBe(child)
expect(input).toEqual([child, root, both])
expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([])
})
it('filters event ranges/types/status without reordering richer records', () => {
const events = [
{ sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true },
{ sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true },
{ sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true },
]
const output = filterEventResults(events, [
{ kind: 'seq', range: { from: 1, to: 2 } },
{ kind: 'time', range: { from: 10, to: 20 } },
{ kind: 'type', values: ['user/message', 'tool/call'] },
{ kind: 'surface', values: ['current', 'shadowed'] },
])
expect(output).toEqual(events.slice(0, 2))
expect(output[0]).toBe(events[0])
})
it('rejects invalid serializable filter values', () => {
expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('handles absent range bounds and root/availability alternatives', () => {
const record: SessionRecord = { header: header('root'), live: false, persisted: true }
expect(filterSessionResults([record], [
{ kind: 'parent', values: [null] },
{ kind: 'cwd', values: [null] },
{ kind: 'availability', values: ['persisted'] },
])).toEqual([record])
const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const }
expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event])
})
})
describe('logical corpus reads and traces', () => {
it('lists, classifies, reads, and traces a live session using detached records', async () => {
const ctx = await liveContext({ readWindowMax: 2 })
const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } })
const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } })
const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] })
const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] })
const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] })
const listed = await ctx.sessionQuery.listSessions()
expect(listed).toEqual([{ header: session.header, live: true, persisted: false }])
listed[0]!.header.createdAt = -1
expect(session.header.createdAt).toBe(20)
expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface))
.toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current'])
const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 })
expect([window.startSeq, window.endSeq]).toEqual([0, 4])
expect(window.target.seq).toBe(answer.seq)
if (window.events[0]?.type !== 'user/message') throw new Error('expected user message')
window.events[0].data.content = []
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({
shadowedBy: summary.seq,
replacementChain: [summary.seq, resummary.seq],
referencedBy: [summary.seq],
})
await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({
shadows: [original.seq],
references: [original.seq],
referencedBy: [resummary.seq],
})
await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] })
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
})
it('turns malformed replacement logs into typed surface failures', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('bad-surface'))
session.append('assistant/message', { turn: 1, step: 1, content: [] }, {
surfaceOp: { op: 'replace', start: 9, end: 9 },
sourceEventSeqs: [],
})
await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('returns complete, partial, deterministic, and cycle-checked lineage', async () => {
const ctx = await liveContext()
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } })
const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } })
const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } })
const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } })
const trace = await ctx.sessionQuery.traceSession(grandchild.id)
expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id])
expect(trace.root?.header.id).toBe(root.id)
const rootTrace = await ctx.sessionQuery.traceSession(root.id)
expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id])
expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id)
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') })
await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const cyclic = await liveContext()
const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') }))
const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') }))
cyclic.sessions.enter(a)
cyclic.sessions.enter(b)
await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
})
it('uses live content over a matching persisted base and scopes persistence failures', async () => {
const common = header('same', 5, { cwd: '/w' })
const persistedOnly = header('persisted', 1)
TestPersistence.reset([
{ meta: common, events: eventLog('persisted version') },
{ meta: persistedOnly, events: eventLog('persisted only') },
])
const ctx = await liveContext()
const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } })
live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const persistenceFiber = await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.listEvents(SessionId('not-listed')))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const records = await ctx.sessionQuery.listSessions()
expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([
[common.id, true, true],
[persistedOnly.id, false, true],
])
const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 })
expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' })
await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 }))
.resolves.toMatchObject({ session: { persisted: true } })
TestPersistence.listFailure = new Error('list unavailable')
await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1)
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.listFailure = undefined
TestPersistence.loadFailure = new Error('load unavailable')
await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE')
await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE'))
await persistenceFiber.dispose()
TestPersistence.loadFailure = undefined
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }])
})
it('rejects immutable source header conflicts', async () => {
TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }])
const ctx = await liveContext()
ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } })
await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
})
describe('provider selection and synchronization', () => {
it('selects one usable provider, validates requests/pages, and disposes registration', async () => {
const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 })
const session = ctx.sessions.create(SessionId('s'))
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
const dispose = ctx.sessionQuery.registerSearchProvider(provider)
const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false }
const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' }
provider.sessionPage = { providerId: provider.id, items: [
{ ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch },
], nextCursor: 'next' }
const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })
expect(page.items).toHaveLength(2)
expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 })
expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello')
await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT'))
provider.eventPage = { providerId: 'wrong', items: [] }
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR'))
provider.eventPage = { providerId: provider.id, items: [] }
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal }))
.resolves.toMatchObject({ providerId: provider.id })
dispose()
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE'))
})
it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('coalesce'))
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
let releaseLive!: () => void
const liveBarrier = new Promise<void>((resolve) => { releaseLive = resolve })
let replacements = 0
provider.replaceLive = async (snapshot) => {
replacements += 1
await liveBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
}
const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
await Promise.resolve()
releaseLive()
await Promise.all([first, second])
expect(replacements).toBe(1)
session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
let releaseCorpus!: () => void
const corpusBarrier = new Promise<void>((resolve) => { releaseCorpus = resolve })
let corpusReplacements = 0
provider.replaceLive = async (snapshot) => {
corpusReplacements += 1
await corpusBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
}
const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' })
const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' })
await Promise.resolve()
releaseCorpus()
await Promise.all([crossFirst, crossSecond])
expect(corpusReplacements).toBe(1)
let releaseSearch!: () => void
const searchBarrier = new Promise<void>((resolve) => { releaseSearch = resolve })
provider.searchSessions = async () => {
await searchBarrier
return { providerId: provider.id, items: [] }
}
const controller = new AbortController()
const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })
await Promise.resolve()
await Promise.resolve()
controller.abort()
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
releaseSearch()
await Promise.resolve()
provider.searchSessions = () => Promise.reject(new Error('search failed'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal }))
.rejects.toThrow('search failed')
})
it('searches a persisted target after corpus reconciliation', async () => {
const persisted = header('event-persisted', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' }))
.resolves.toMatchObject({ providerId: provider.id })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target')
})
it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => {
const ctx = await liveContext()
const first = new FakeProvider('first')
ctx.sessionQuery.registerSearchProvider(first)
expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER'))
const second = new FakeProvider('second')
ctx.sessionQuery.registerSearchProvider(second)
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS'))
const configured = await liveContext({ searchProvider: 'chosen' })
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING'))
const chosen = new FakeProvider('chosen')
chosen.statusValue = { available: false, reason: 'unavailable' }
configured.sessionQuery.registerSearchProvider(chosen)
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE'))
chosen.statusValue = { available: true }
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' })
})
it('removes provider registrations with their contributing fiber', async () => {
const ctx = await liveContext()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped'))
}, { inject: ['sessionQuery'] }))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' })
await fiber.dispose()
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE'))
})
it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => {
const persisted = header('persisted', 1)
const overlaid = header('overlaid', 1)
TestPersistence.reset([
{ meta: persisted, events: eventLog('persisted') },
{ meta: overlaid, events: eventLog('base') },
])
const ctx = await liveContext()
const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } })
live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const persistenceFiber = await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
provider.failNextActive = true
provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] })
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted')
expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override')
expect(provider.removedPersisted).toEqual([SessionId('stale')])
expect(provider.activeHistory.at(-1)).toBe(true)
const fingerprint = provider.persisted.get(persisted.id)?.fingerprint
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint)
const announced = header('announced', 3)
TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') })
await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 })
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced')
provider.failNextActive = true
await persistenceFiber.dispose()
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.activeHistory.at(-1)).toBe(false)
expect(provider.persisted.has(persisted.id)).toBe(true)
})
it('synchronizes only a live target for event search and retries dirty failures', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('target'))
session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' })
expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one')
session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
provider.failNextLive = true
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id })
expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two'])
const controller = new AbortController()
controller.abort()
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal }))
.rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
it('removes a disposed live override and reveals the provider base', async () => {
const persisted = header('fallback', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('base') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
let session!: Session
const liveFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } })
session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}, { inject: ['sessions'] }))
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.live.has(session.id)).toBe(true)
await liveFiber.dispose()
await Promise.resolve()
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.removedLive).toContain(session.id)
expect(provider.live.has(session.id)).toBe(false)
expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base')
})
it('retries failed persisted reconciliation without affecting canonical writes', async () => {
const persisted = header('retry', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
provider.failNextPersisted = true
ctx.sessionQuery.registerSearchProvider(provider)
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry')
})
})
describe('semantic text extractors', () => {
it('indexes core semantic text and excludes chunks and structural events', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('semantic'))
const nested: ContentBlock[] = [
{ type: 'text', text: 'visible' },
{ type: 'reasoning', text: 'thinking' },
{ type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' },
{ type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] },
]
session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' })
session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } })
session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } })
session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } })
session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } })
session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } })
session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } })
session.append('turn/end', { turn: 8, reason: { kind: 'completed' } })
session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } })
session.append('step/start', { turn: 1, step: 2 })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const documents = provider.live.get(session.id)?.documents ?? []
expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result')
expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason')
expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED')
expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests')
expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL')
expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled')
expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail')
expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted')
expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk')
})
it('supports versioned effect-scoped custom event and content extractors', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('custom'))
session.append('test/note', { note: 'event note' })
session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
let disposeEvent!: () => void
let disposeContent!: () => void
const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => {
disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] })
disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] })
}, { inject: ['sessionQuery'] }))
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const first = provider.live.get(session.id)
expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note'])
expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR'))
expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR'))
expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR'))
disposeEvent()
disposeContent()
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const second = provider.live.get(session.id)
expect(second?.documents).toEqual([])
expect(second?.fingerprint).not.toBe(first?.fingerprint)
await extractorFiber.dispose()
})
})
describe('configuration', () => {
it('rejects an impossible default page size and exposes typed errors', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
const error = new SessionQueryError('test', 'SESSION_QUERY_TEST')
expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' })
})
it('uses constructor defaults and removes the service on plugin disposal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionQueryService)
const session = ctx.sessions.create(SessionId('defaults'))
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await fiber.dispose()
expect(ctx.sessionQuery).toBeUndefined()
const direct = new Context()
await direct.plugin(SessionStore)
const service = new SessionQueryService(direct, {})
const directSession = direct.sessions.create(SessionId('direct-defaults'))
await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await direct.fiber.dispose()
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
}
]
}