feat(session-query): add SQLite full-text search
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
|
||||
|
||||
This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
|
||||
@@ -8,11 +8,24 @@ This is trusted context-wide infrastructure. It performs no caller authorization
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
## Filtering and extraction
|
||||
|
||||
`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`.
|
||||
|
||||
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
|
||||
|
||||
## Full-text seam
|
||||
|
||||
`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
|
||||
The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
|
||||
`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -20,4 +33,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis
|
||||
|---|---:|---|
|
||||
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
|
||||
|
||||
This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
The package deliberately has no lineage/provenance traversal, extractor registry, search-provider registry, index synchronization, caller authorization, or model-facing tool. The SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Public configuration and typed failures for session-query. */
|
||||
/** Public configuration and typed failures for session-query and search. */
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
@@ -11,14 +11,21 @@ export interface Config {
|
||||
readWindowMax?: number
|
||||
}
|
||||
|
||||
/** Stable machine-routable failure taxonomy for exact session reads. */
|
||||
/** Stable machine-routable failure taxonomy for session reads and search. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_ABORTED'
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INDEX_FAILED'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_CURSOR'
|
||||
| 'SESSION_QUERY_INVALID_FILTER'
|
||||
| 'SESSION_QUERY_INVALID_LIMIT'
|
||||
| 'SESSION_QUERY_INVALID_QUERY'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_STALE_CURSOR'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
/** Typed session-query failure whose `code` is one closed taxonomy member. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
|
||||
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionRecord } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import { assertSessionHeadersCompatible } from './sources.ts'
|
||||
|
||||
/** Detached source selected for one exact read. */
|
||||
export interface LogicalSession {
|
||||
@@ -45,7 +46,7 @@ export class SessionCorpus {
|
||||
}
|
||||
for (const session of this._ctx.sessions.list()) {
|
||||
const durable = records.get(session.id)
|
||||
if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header)
|
||||
if (durable !== undefined) assertSessionHeadersCompatible(session.header, durable.header)
|
||||
records.set(session.id, {
|
||||
header: structuredClone(session.header),
|
||||
live: true,
|
||||
@@ -80,7 +81,7 @@ export class SessionCorpus {
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
assertCompatibleHeaders(loaded.meta, listed)
|
||||
assertSessionHeadersCompatible(loaded.meta, listed)
|
||||
return {
|
||||
header: structuredClone(loaded.meta),
|
||||
events: loaded.events.map(event => structuredClone(event)),
|
||||
@@ -107,22 +108,6 @@ function snapshotLive(session: Session): LogicalSession {
|
||||
}
|
||||
}
|
||||
|
||||
function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
|
||||
if (
|
||||
a.version !== b.version
|
||||
|| a.id !== b.id
|
||||
|| a.createdAt !== b.createdAt
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`live and persisted headers conflict for session "${a.id}"`,
|
||||
'SESSION_QUERY_SOURCE_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function compareSessions(a: SessionRecord, b: SessionRecord): number {
|
||||
return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id)
|
||||
}
|
||||
|
||||
74
packages/session-query/session-query/src/documents.ts
Normal file
74
packages/session-query/session-query/src/documents.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** Shared event metadata and semantic-document projection. */
|
||||
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import { extractSessionEventText } from './extraction.ts'
|
||||
|
||||
/**
|
||||
* Project a raw log into lightweight surface-aware event records.
|
||||
* @param sessionId - session that owns the log.
|
||||
* @param events - complete contiguous raw event log.
|
||||
* @returns one record per event in ascending seq order.
|
||||
*/
|
||||
export function buildSessionEventRecords(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventRecord[] {
|
||||
const surfaceBySeq = classifySurface(events)
|
||||
return events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build first-party semantic documents for one complete raw event log.
|
||||
* @param sessionId - session that owns the log.
|
||||
* @param events - complete contiguous raw event log.
|
||||
* @returns searchable documents in ascending seq order; structural events are omitted.
|
||||
*/
|
||||
export function buildSessionEventSearchDocuments(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventSearchDocument[] {
|
||||
const surfaceBySeq = classifySurface(events)
|
||||
const documents: SessionEventSearchDocument[] = []
|
||||
for (const event of events) {
|
||||
const text = extractSessionEventText(event)
|
||||
if (text.length === 0) continue
|
||||
documents.push({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
|
||||
text,
|
||||
})
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
function classifySurface(events: readonly SessionEvent[]): Map<number, SessionEventSurface> {
|
||||
let folded: ReturnType<typeof foldSurface>
|
||||
try {
|
||||
folded = foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const result = new Map<number, SessionEventSurface>()
|
||||
for (const node of folded.nodes) result.set(node.seq, 'current')
|
||||
for (const replacement of folded.replacements) {
|
||||
for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed')
|
||||
}
|
||||
return result
|
||||
}
|
||||
93
packages/session-query/session-query/src/extraction.ts
Normal file
93
packages/session-query/session-query/src/extraction.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** First-party semantic text extraction for session-query consumers. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Extract searchable semantic text from one first-party session event.
|
||||
*
|
||||
* Structural boundaries, raw stream chunks, request envelopes, and unknown
|
||||
* declaration-merged events contribute no text.
|
||||
* @param event - event to inspect.
|
||||
* @returns newline-joined semantic text, or an empty string when non-searchable.
|
||||
*/
|
||||
export function extractSessionEventText(event: SessionEvent): string {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
return contentText(event.data.content)
|
||||
case 'prompt/blocked':
|
||||
return joinText([contentText(event.data.content), event.data.reason])
|
||||
case 'tool/call':
|
||||
return joinText([event.data.name, event.data.arguments])
|
||||
case 'tool/result':
|
||||
return joinText([
|
||||
contentText(event.data.content),
|
||||
event.data.error?.name ?? '',
|
||||
event.data.error?.code ?? '',
|
||||
])
|
||||
case 'todo/write':
|
||||
return joinText(event.data.todos.flatMap(todo => [todo.status, todo.content]))
|
||||
case 'turn/end':
|
||||
return turnEndText(event.data.reason)
|
||||
case 'turn/start':
|
||||
case 'step/start':
|
||||
case 'step/end':
|
||||
case 'assistant/chunk':
|
||||
case 'request/header':
|
||||
case 'request/header-delta':
|
||||
return ''
|
||||
// SessionEventMap is merge-extensible. Unknown events remain
|
||||
// non-searchable until a concrete first-party consumer defines semantics.
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
|
||||
switch (reason.kind) {
|
||||
case 'error':
|
||||
return joinText(['error', reason.message, reason.code ?? ''])
|
||||
case 'aborted':
|
||||
return joinText(['aborted', reason.reason ?? ''])
|
||||
case 'rejected':
|
||||
return joinText(['rejected', reason.reason])
|
||||
case 'disposed':
|
||||
case 'max-tokens':
|
||||
case 'interrupted':
|
||||
return reason.kind
|
||||
case 'completed':
|
||||
return ''
|
||||
// TurnEndReasonMap is merge-extensible. Unknown outcomes stay out until
|
||||
// their owner defines which detail is semantic rather than structural.
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
type SessionContentBlock = SessionEvent<'user/message'>['data']['content'][number]
|
||||
|
||||
function contentText(content: readonly SessionContentBlock[]): string {
|
||||
return joinText(content.flatMap(blockText))
|
||||
}
|
||||
|
||||
function blockText(block: SessionContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
return [block.text]
|
||||
case 'tool-call':
|
||||
return [block.name, block.arguments]
|
||||
case 'tool-result':
|
||||
return block.content.flatMap(blockText)
|
||||
// ContentBlockMap is merge-extensible. Unknown blocks do not become
|
||||
// searchable merely because their payload happens to contain strings.
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function joinText(parts: readonly string[]): string {
|
||||
return parts.map(part => part.trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
132
packages/session-query/session-query/src/filters.ts
Normal file
132
packages/session-query/session-query/src/filters.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** Pure provider-independent predicates for logical sessions and event text. */
|
||||
|
||||
import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/**
|
||||
* Apply ANDed logical-session filters while preserving input order.
|
||||
* @param records - detached logical-session records to inspect.
|
||||
* @param filters - clauses whose list values are ORed within each clause.
|
||||
* @returns records accepted by every clause.
|
||||
*/
|
||||
export function filterSessionResults<T extends SessionRecord>(
|
||||
records: readonly T[],
|
||||
filters: readonly SessionResultFilter[] = [],
|
||||
): T[] {
|
||||
const predicates = filters.map(sessionPredicate)
|
||||
return records.filter(record => predicates.every(predicate => predicate(record)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply ANDed event filters to extracted semantic documents.
|
||||
* @param documents - semantic documents produced by {@link buildSessionEventSearchDocuments}.
|
||||
* @param filters - metadata and literal-text predicates.
|
||||
* @returns documents accepted by every clause, in input order.
|
||||
*/
|
||||
export function filterSessionEventDocuments<T extends SessionEventSearchDocument>(
|
||||
documents: readonly T[],
|
||||
filters: readonly SessionEventResultFilter[] = [],
|
||||
): T[] {
|
||||
const predicates = filters.map(eventPredicate)
|
||||
return documents.filter(document => predicates.every(predicate => predicate(document)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a literal case-insensitive, whitespace-flexible semantic-text match.
|
||||
* @param text - caller-provided literal text.
|
||||
* @returns Unicode-aware regular expression safe from regex injection.
|
||||
*/
|
||||
export function compileSessionTextFilter(text: string): RegExp {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new SessionQueryError(
|
||||
'session text filter must contain non-whitespace text',
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
const pattern = trimmed
|
||||
.split(/\s+/u)
|
||||
.map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
|
||||
.join('\\s+')
|
||||
return new RegExp(pattern, 'iu')
|
||||
}
|
||||
|
||||
function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) => boolean {
|
||||
switch (filter.kind) {
|
||||
case 'id':
|
||||
return record => filter.values.includes(record.header.id)
|
||||
case 'cwd':
|
||||
return record => filter.values.includes(record.header.cwd ?? null)
|
||||
case 'created-at': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return record => matchesRange(record.header.createdAt, range)
|
||||
}
|
||||
case 'parent':
|
||||
return record => filter.values.includes(record.header.parentSession ?? null)
|
||||
case 'availability':
|
||||
assertAllowedValues(filter.kind, filter.values, ['live', 'persisted'])
|
||||
return record => filter.values.some(value => value === 'live' ? record.live : record.persisted)
|
||||
}
|
||||
}
|
||||
|
||||
function eventPredicate(filter: SessionEventResultFilter): (document: SessionEventSearchDocument) => boolean {
|
||||
switch (filter.kind) {
|
||||
case 'seq': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return document => matchesRange(document.seq, range)
|
||||
}
|
||||
case 'time': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return document => matchesRange(document.time, range)
|
||||
}
|
||||
case 'type':
|
||||
return document => filter.values.includes(document.type)
|
||||
case 'surface':
|
||||
assertAllowedValues(filter.kind, filter.values, ['current', 'shadowed', 'log-only'])
|
||||
return document => filter.values.includes(document.surface)
|
||||
case 'text': {
|
||||
const pattern = compileSessionTextFilter(filter.text)
|
||||
return document => pattern.test(document.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedValues(
|
||||
name: string,
|
||||
values: readonly string[],
|
||||
allowed: readonly string[],
|
||||
): void {
|
||||
for (const value of values) {
|
||||
if (!allowed.includes(value)) {
|
||||
throw new SessionQueryError(
|
||||
`session ${name} filter contains unknown value "${value}"`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRange(name: string, range: SessionResultRange): SessionResultRange {
|
||||
if (range.from !== undefined && !Number.isFinite(range.from)) {
|
||||
throw invalidRange(name, 'from must be finite')
|
||||
}
|
||||
if (range.to !== undefined && !Number.isFinite(range.to)) {
|
||||
throw invalidRange(name, 'to must be finite')
|
||||
}
|
||||
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
|
||||
throw invalidRange(name, 'from must be less than or equal to to')
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
function matchesRange(value: number, range: SessionResultRange): boolean {
|
||||
return (range.from === undefined || value >= range.from)
|
||||
&& (range.to === undefined || value <= range.to)
|
||||
}
|
||||
|
||||
function invalidRange(name: string, detail: string): SessionQueryError {
|
||||
return new SessionQueryError(
|
||||
`session ${name} filter ${detail}`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
@@ -6,13 +6,20 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchRequest,
|
||||
SessionEventWindow,
|
||||
SessionRecord,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
@@ -20,17 +27,58 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
import { filterSessionEventDocuments } from './filters.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
|
||||
export { extractSessionEventText } from './extraction.ts'
|
||||
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts'
|
||||
export { assertSessionHeadersCompatible } from './sources.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
sessionQuery: SessionQueryService
|
||||
sessionSearch: SessionSearchService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract full-text search service implemented by one concrete backend.
|
||||
*
|
||||
* The implementation owns source observation, reconciliation, cursor
|
||||
* generations, ranking, and query execution as one lifecycle.
|
||||
*/
|
||||
export abstract class SessionSearchService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionSearch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the live-preferred logical corpus and group by session.
|
||||
* @param request - query text, metadata filters, page size, and cursor.
|
||||
* @param exec - optional cancellation control.
|
||||
* @returns session hits ranked by their strongest matching event.
|
||||
*/
|
||||
abstract searchSessions(
|
||||
request: SessionSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionSearchHit>>
|
||||
|
||||
/**
|
||||
* Search events within one live-preferred logical session.
|
||||
* @param request - target session, query text, filters, page size, and cursor.
|
||||
* @param exec - optional cancellation control.
|
||||
* @returns matching event hits in deterministic relevance order.
|
||||
*/
|
||||
abstract searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>>
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus and exact-event read service. */
|
||||
export class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
@@ -68,7 +116,22 @@ export class SessionQueryService extends Service {
|
||||
*/
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return eventRecords(sessionId, loaded.events)
|
||||
return buildSessionEventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan first-party semantic event documents with provider-independent filters.
|
||||
* @param sessionId - live-preferred session id to scan.
|
||||
* @param filters - ANDed metadata and literal-text predicates.
|
||||
* @returns matching semantic documents in ascending seq order.
|
||||
*/
|
||||
async filterEvents(
|
||||
sessionId: SessionId,
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): Promise<SessionEventSearchDocument[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
|
||||
return filterSessionEventDocuments(documents, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,27 +173,4 @@ export class SessionQueryService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
|
||||
let folded: ReturnType<typeof foldSurface>
|
||||
try {
|
||||
folded = foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const current = new Set(folded.nodes.map(node => node.seq))
|
||||
const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
|
||||
return events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
|
||||
}))
|
||||
}
|
||||
|
||||
export default SessionQueryService
|
||||
|
||||
25
packages/session-query/session-query/src/sources.ts
Normal file
25
packages/session-query/session-query/src/sources.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Shared immutable-header checks for logical session source observers. */
|
||||
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/**
|
||||
* Reject incompatible observations of one logical session source.
|
||||
* @param a - first live, listed, or loaded header observation.
|
||||
* @param b - second header observation expected to identify the same source.
|
||||
*/
|
||||
export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeader): void {
|
||||
if (
|
||||
a.version !== b.version
|
||||
|| a.id !== b.id
|
||||
|| a.createdAt !== b.createdAt
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`session source headers conflict for session "${a.id}"`,
|
||||
'SESSION_QUERY_SOURCE_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -58,3 +58,99 @@ export interface SessionEventWindow {
|
||||
/** Last seq included in `events`. */
|
||||
endSeq: number
|
||||
}
|
||||
|
||||
/** Inclusive numeric interval used by time and sequence filters. */
|
||||
export interface SessionResultRange {
|
||||
/** Inclusive lower bound. */
|
||||
from?: number
|
||||
/** Inclusive upper bound. */
|
||||
to?: number
|
||||
}
|
||||
|
||||
/** Source availability predicates understood by logical-session filters. */
|
||||
export type SessionAvailability = 'live' | 'persisted'
|
||||
|
||||
/**
|
||||
* One logical-session predicate. A filter array is ANDed; `values` within a
|
||||
* clause are ORed.
|
||||
*/
|
||||
export type SessionResultFilter =
|
||||
| { kind: 'id'; values: readonly SessionId[] }
|
||||
| { kind: 'cwd'; values: readonly (string | null)[] }
|
||||
| ({ kind: 'created-at' } & SessionResultRange)
|
||||
| { kind: 'parent'; values: readonly (SessionId | null)[] }
|
||||
| { kind: 'availability'; values: readonly SessionAvailability[] }
|
||||
|
||||
/**
|
||||
* One event predicate. A filter array is ANDed; list-valued clauses are ORed.
|
||||
* Text is a literal, case-insensitive, whitespace-flexible semantic-text scan.
|
||||
*/
|
||||
export type SessionEventResultFilter =
|
||||
| ({ kind: 'seq' } & SessionResultRange)
|
||||
| ({ kind: 'time' } & SessionResultRange)
|
||||
| { kind: 'type'; values: readonly SessionEventType[] }
|
||||
| { kind: 'surface'; values: readonly SessionEventSurface[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
/** Event predicates a full-text provider can apply before relevance ranking. */
|
||||
export type SessionEventMetadataFilter = Exclude<SessionEventResultFilter, { kind: 'text' }>
|
||||
|
||||
/** Searchable semantic document derived from one session event. */
|
||||
export interface SessionEventSearchDocument extends SessionEventRecord {
|
||||
/** First-party semantic text used by scan filters and full-text indexes. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One cursor-paginated result page. */
|
||||
export interface SessionSearchPage<T> {
|
||||
/** Results for this page in contract-defined order. */
|
||||
items: readonly T[]
|
||||
/** Opaque continuation cursor, absent on the final page. */
|
||||
nextCursor?: string
|
||||
}
|
||||
|
||||
/** Controls shared by cross-session and within-session search calls. */
|
||||
export interface SessionSearchExecContext {
|
||||
/** Abort caller waiting and interrupt provider work where supported. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Cross-session full-text search request. */
|
||||
export interface SessionSearchRequest {
|
||||
/** Full-text query interpreted as data, never executable FTS syntax. */
|
||||
query: string
|
||||
/** Logical-session predicates applied before event ranking. */
|
||||
sessionFilters?: readonly SessionResultFilter[]
|
||||
/** Event predicates applied before event ranking. */
|
||||
eventFilters?: readonly SessionEventMetadataFilter[]
|
||||
/** Maximum sessions in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** Within-session full-text search request. */
|
||||
export interface SessionEventSearchRequest {
|
||||
/** Session whose live-preferred logical log is searched. */
|
||||
sessionId: SessionId
|
||||
/** Full-text query interpreted as data, never executable FTS syntax. */
|
||||
query: string
|
||||
/** Event predicates applied before ranking. */
|
||||
filters?: readonly SessionEventMetadataFilter[]
|
||||
/** Maximum events in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
/** One event full-text search hit with a bounded plain-text excerpt. */
|
||||
export interface SessionEventSearchHit extends SessionEventRecord {
|
||||
/** Plain text excerpt selected around the match. */
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** One grouped cross-session hit, ranked by its strongest matching event. */
|
||||
export interface SessionSearchHit extends SessionRecord {
|
||||
/** Strongest matching event for this session. */
|
||||
bestMatch: SessionEventSearchHit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionQueryService, {
|
||||
buildSessionEventRecords,
|
||||
buildSessionEventSearchDocuments,
|
||||
compileSessionTextFilter,
|
||||
extractSessionEventText,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
SessionSearchService,
|
||||
type SessionEventSearchHit,
|
||||
type SessionEventSearchRequest,
|
||||
type SessionQueryErrorCode,
|
||||
type SessionSearchExecContext,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchPage,
|
||||
type SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
const id = SessionId('session')
|
||||
|
||||
function header(value: string, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra }
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
describe('session-query semantic extraction', () => {
|
||||
it('extracts first-party message, tool, todo, and failure detail', () => {
|
||||
const callId = CallId('call')
|
||||
const messageContent: SessionEvent<'user/message'>['data']['content'] = [
|
||||
{ type: 'text', text: ' visible ' },
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text: 'nested' }],
|
||||
isError: false,
|
||||
},
|
||||
{ type: 'future-content', payload: 'hidden' } as never,
|
||||
]
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' },
|
||||
{ type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
|
||||
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } },
|
||||
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } },
|
||||
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' },
|
||||
{ type: 'tool/result', seq: 7, time: 8, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' },
|
||||
{ type: 'todo/write', seq: 8, time: 9, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } },
|
||||
]
|
||||
|
||||
for (const event of events.slice(0, 4)) {
|
||||
expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested')
|
||||
}
|
||||
expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy')
|
||||
expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}')
|
||||
expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS')
|
||||
expect(extractSessionEventText(events[7]!)).toBe('')
|
||||
expect(extractSessionEventText(events[8]!)).toBe('in_progress\nship search')
|
||||
})
|
||||
|
||||
it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
|
||||
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
|
||||
[{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'],
|
||||
[{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'],
|
||||
[{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
|
||||
[{ kind: 'aborted' }, 'aborted'],
|
||||
[{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'],
|
||||
[{ kind: 'disposed' }, 'disposed'],
|
||||
[{ kind: 'max-tokens' }, 'max-tokens'],
|
||||
[{ kind: 'interrupted' }, 'interrupted'],
|
||||
[{ kind: 'completed' }, ''],
|
||||
[{ kind: 'future-status' } as never, ''],
|
||||
]
|
||||
for (const [reason, text] of reasons) {
|
||||
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text)
|
||||
}
|
||||
const structural: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'request/header-delta', seq: 5, time: 1, data: {} },
|
||||
{ type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never,
|
||||
]
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', ''])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-query document and filter helpers', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
|
||||
]
|
||||
|
||||
it('classifies every event and omits non-semantic documents', () => {
|
||||
expect(buildSessionEventRecords(id, events).map(record => record.surface))
|
||||
.toEqual(['shadowed', 'log-only', 'current', 'log-only'])
|
||||
const documents = buildSessionEventSearchDocuments(id, events)
|
||||
expect(documents.map(document => [document.seq, document.text, document.surface])).toEqual([
|
||||
[0, 'Hello\n(AI)+', 'shadowed'],
|
||||
[2, 'replacement', 'current'],
|
||||
[3, 'interrupted', 'log-only'],
|
||||
])
|
||||
})
|
||||
|
||||
it('applies every session clause with OR values and validates closed values', () => {
|
||||
const parent = SessionId('parent')
|
||||
const records = [
|
||||
{ header: header('a', { cwd: '/a', parentSession: parent }), live: true, persisted: false, marker: 1 },
|
||||
{ header: header('b', { createdAt: 20 }), live: false, persisted: true, marker: 2 },
|
||||
]
|
||||
expect(filterSessionResults(records, [
|
||||
{ kind: 'id', values: [SessionId('a'), SessionId('x')] },
|
||||
{ kind: 'cwd', values: ['/a', null] },
|
||||
{ kind: 'created-at', from: 5, to: 15 },
|
||||
{ kind: 'parent', values: [parent, null] },
|
||||
{ kind: 'availability', values: ['live'] },
|
||||
])).toEqual([records[0]])
|
||||
expect(filterSessionResults(records, [{ kind: 'cwd', values: [null] }])).toEqual([records[1]])
|
||||
expect(filterSessionResults(records, [{ kind: 'parent', values: [null] }])).toEqual([records[1]])
|
||||
expect(filterSessionResults(records, [{ kind: 'availability', values: ['persisted'] }])).toEqual([records[1]])
|
||||
expect(() => filterSessionResults(records, [{ kind: 'availability', values: ['remote' as never] }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('applies event metadata and safe literal text clauses', () => {
|
||||
const documents = buildSessionEventSearchDocuments(id, events).map((document, marker) => ({ ...document, marker }))
|
||||
expect(filterSessionEventDocuments(documents, [
|
||||
{ kind: 'seq', from: 0, to: 1 },
|
||||
{ kind: 'time', from: 9, to: 11 },
|
||||
{ kind: 'type', values: ['user/message', 'tool/result'] },
|
||||
{ kind: 'surface', values: ['shadowed'] },
|
||||
{ kind: 'text', text: 'hello (ai)+' },
|
||||
])).toEqual([documents[0]])
|
||||
expect(compileSessionTextFilter('CAFÉ').test('café')).toBe(true)
|
||||
expect(filterSessionEventDocuments(documents)).toEqual(documents)
|
||||
expect(filterSessionEventDocuments(documents, [{ kind: 'surface', values: [] }])).toEqual([])
|
||||
expect(() => filterSessionEventDocuments(documents, [{ kind: 'surface', values: ['future' as never] }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => compileSessionTextFilter(' \n ')).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('rejects malformed range filters and malformed surfaces', () => {
|
||||
const documents = buildSessionEventSearchDocuments(id, events)
|
||||
for (const filter of [
|
||||
{ kind: 'seq', from: Number.NaN },
|
||||
{ kind: 'seq', to: Number.POSITIVE_INFINITY },
|
||||
{ kind: 'time', from: 2, to: 1 },
|
||||
] as const) {
|
||||
expect(() => filterSessionEventDocuments(documents, [filter]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
}
|
||||
expect(() => filterSessionResults([], [{ kind: 'created-at', from: Number.NaN }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionResults([{ header: header('x'), live: true, persisted: false }], [
|
||||
{ kind: 'created-at', from: Number.NaN },
|
||||
])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
const malformed: SessionEvent[] = [{
|
||||
type: 'assistant/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
}]
|
||||
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('exposes the scan path on the concrete exact-read service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
const session = ctx.sessions.create(id)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }]))
|
||||
.resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }])
|
||||
})
|
||||
})
|
||||
|
||||
class TestSearchService extends SessionSearchService {
|
||||
searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
}
|
||||
|
||||
it('registers the abstract search seam under its independent ctx key', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = await ctx.plugin(TestSearchService)
|
||||
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionSearch).toBeUndefined()
|
||||
})
|
||||
Reference in New Issue
Block a user