index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * Service Definition for combined session-history reads, traces, filters, and full-text search.
  3. *
  4. * @module @deepseek-ai/dsh-session-query
  5. */
  6. import { Context, Service } from '@deepseek-ai/cordis'
  7. import {
  8. Session,
  9. SessionSeq,
  10. snapshotSessionEvent,
  11. type SessionId,
  12. type SessionSeq as SessionSeqType,
  13. } from '@deepseek-ai/dsh-session'
  14. import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
  15. import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
  16. import type {
  17. SessionEventResultFilter,
  18. SessionEventSearchPage,
  19. SessionEventReadRequest,
  20. SessionEventRecord,
  21. SessionEventSearchDocument,
  22. SessionEventSearchRequest,
  23. SessionEventTraceObservation,
  24. SessionEventTraceRequest,
  25. SessionEventWindow,
  26. SessionLineageTrace,
  27. SessionLogSnapshot,
  28. SessionRecord,
  29. SessionResultFilter,
  30. SessionSearchExecContext,
  31. SessionSearchHit,
  32. SessionSearchPage,
  33. SessionSearchRequest,
  34. SessionSurfaceSnapshot,
  35. SessionTitleObservation,
  36. SessionTitleObservationResult,
  37. } from './types.ts'
  38. import {
  39. SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
  40. SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE,
  41. SESSION_QUERY_READ_WINDOW_MAX,
  42. SessionQueryError,
  43. type Config,
  44. } from './config.ts'
  45. import { SessionCorpus } from './corpus.ts'
  46. import {
  47. SessionObservationReader,
  48. type SessionObservation,
  49. type SessionObservationOptions,
  50. } from './observation.ts'
  51. import { buildSessionEventSearchDocuments } from './documents.ts'
  52. import {
  53. filterSessionEventDocuments,
  54. filterSessionResults,
  55. materializeSessionEventResultFilters,
  56. materializeSessionResultFilters,
  57. } from './filters.ts'
  58. import * as tracing from './tracing.ts'
  59. export type * from './types.ts'
  60. export { SessionSearchCursor } from './cursor.ts'
  61. export type { Config, SessionQueryErrorCode } from './config.ts'
  62. export {
  63. SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
  64. SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE,
  65. SESSION_QUERY_READ_WINDOW_MAX,
  66. SessionQueryError,
  67. } from './config.ts'
  68. export { readColdSessionLog } from './cold-read.ts'
  69. export type { ColdSessionLog } from './cold-read.ts'
  70. export { extractSessionEventText } from './extraction.ts'
  71. export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
  72. export {
  73. compileSessionTextFilter,
  74. filterSessionEventDocuments,
  75. filterSessionResults,
  76. materializeSessionEventResultFilters,
  77. materializeSessionResultFilters,
  78. } from './filters.ts'
  79. export { assertSessionHeadersCompatible } from './sources.ts'
  80. export type { SessionObservation, SessionObservationOptions } from './observation.ts'
  81. declare module '@deepseek-ai/cordis' {
  82. interface Context {
  83. sessionQuery: SessionQueryEngine
  84. }
  85. }
  86. /**
  87. * Unified live-preferred session query service.
  88. *
  89. * Exact reads, filters, and traces are backend-independent concrete behavior.
  90. * A backend implements full-text observation, reconciliation, ranking, cursor
  91. * generations, and query execution on the same `ctx.sessionQuery` service.
  92. */
  93. export abstract class SessionQueryEngine extends Service {
  94. static inject = ['sessions']
  95. private readonly _readWindowMax: number
  96. private readonly _corpus: SessionCorpus
  97. private readonly _observations: SessionObservationReader
  98. constructor(ctx: Context, config: Config = {}) {
  99. super(ctx, 'sessionQuery')
  100. this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX
  101. if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) {
  102. throw new SessionQueryError(
  103. 'session-query: readWindowMax must be a non-negative integer',
  104. 'SESSION_QUERY_INVALID_CONFIG',
  105. )
  106. }
  107. const persistedReadConcurrency = config.persistedReadConcurrency
  108. ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY
  109. if (!Number.isSafeInteger(persistedReadConcurrency) || persistedReadConcurrency < 1) {
  110. throw new SessionQueryError(
  111. 'session-query: persistedReadConcurrency must be a positive safe integer',
  112. 'SESSION_QUERY_INVALID_CONFIG',
  113. )
  114. }
  115. const preparedSessionCacheSize = config.preparedSessionCacheSize
  116. ?? SESSION_QUERY_DEFAULT_PREPARED_SESSION_CACHE_SIZE
  117. if (!Number.isSafeInteger(preparedSessionCacheSize) || preparedSessionCacheSize < 1) {
  118. throw new SessionQueryError(
  119. 'session-query: preparedSessionCacheSize must be a positive safe integer',
  120. 'SESSION_QUERY_INVALID_CONFIG',
  121. )
  122. }
  123. this._corpus = new SessionCorpus(ctx, persistedReadConcurrency)
  124. this._observations = new SessionObservationReader(ctx, preparedSessionCacheSize)
  125. }
  126. /**
  127. * Observe one exact live or prepared Session without a persistence listing preflight.
  128. * @param sessionId - logical Session identity.
  129. * @param options - cancellation and projection selection for this read.
  130. * @returns a caller-owned observation lease.
  131. */
  132. observeSession(
  133. sessionId: SessionId,
  134. options: SessionObservationOptions = {},
  135. ): Promise<SessionObservation> {
  136. return this._observations.read(sessionId, options)
  137. }
  138. /**
  139. * Search the live-preferred logical corpus and group by session.
  140. * @param request - query text, metadata filters, page size, and cursor.
  141. * @param exec - optional cancellation control.
  142. * @returns session hits ranked by their strongest matching event.
  143. */
  144. abstract searchSessions(
  145. request: SessionSearchRequest,
  146. exec?: SessionSearchExecContext,
  147. ): Promise<SessionSearchPage<SessionSearchHit>>
  148. /**
  149. * Search events within one live-preferred logical session.
  150. * @param request - target session, query text, filters, page size, and cursor.
  151. * @param exec - optional cancellation control.
  152. * @returns matching event hits and their target header from one indexed generation.
  153. */
  154. abstract searchEvents(
  155. request: SessionEventSearchRequest,
  156. exec?: SessionSearchExecContext,
  157. ): Promise<SessionEventSearchPage>
  158. /**
  159. * List the complete logical corpus using live-preferred records.
  160. * @param signal - optional cancellation for persistence listing.
  161. * @returns deterministic newest-first cloned session records.
  162. */
  163. listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
  164. return this._corpus.listSessions(signal)
  165. }
  166. /**
  167. * Read and replay-validate one complete logical session log without making it live.
  168. * @param sessionId - live or persisted session id to read.
  169. * @returns cloned header and complete raw event log from one observation.
  170. * @throws when persistence, header compatibility, or replay validation fails.
  171. */
  172. async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> {
  173. const loaded = await this._corpus.load(sessionId)
  174. Session.create(
  175. sessionId,
  176. loaded.events,
  177. loaded.header,
  178. loaded.inheritedEventCount,
  179. )
  180. return {
  181. session: structuredClone(loaded.header),
  182. inheritedEventCount: loaded.inheritedEventCount,
  183. events: loaded.events.map(snapshotSessionEvent),
  184. }
  185. }
  186. /**
  187. * Filter the complete logical corpus with provider-independent predicates.
  188. * @param filters - ANDed session metadata and availability clauses.
  189. * @param signal - optional cancellation for persistence listing.
  190. * @returns matching cloned records in deterministic newest-first order.
  191. */
  192. async filterSessions(
  193. filters: readonly SessionResultFilter[],
  194. signal?: AbortSignal,
  195. ): Promise<SessionRecord[]> {
  196. const ownedFilters = materializeSessionResultFilters(filters)
  197. return this._filterSessions(ownedFilters, signal)
  198. }
  199. /**
  200. * Fold the latest log-backed title from one live-preferred logical session.
  201. * @param sessionId - live or persisted session id to read.
  202. * @param signal - optional cancellation for source resolution and title folding.
  203. * @returns latest title snapshot, or `undefined` when the log has no title event.
  204. */
  205. async readTitle(
  206. sessionId: SessionId,
  207. signal?: AbortSignal,
  208. ): Promise<SessionTitleSnapshot | undefined> {
  209. return (await this.readTitleSnapshot(sessionId, signal)).title
  210. }
  211. /**
  212. * Fold the latest title and return its source header from one corpus observation.
  213. * @param sessionId - live or persisted session id to read.
  214. * @param signal - optional cancellation for source resolution and title folding.
  215. * @returns cloned source header and optional latest title snapshot.
  216. */
  217. async readTitleSnapshot(
  218. sessionId: SessionId,
  219. signal?: AbortSignal,
  220. ): Promise<SessionTitleObservation> {
  221. const result = (await this.readTitleSnapshots([sessionId], signal))[0] as SessionTitleObservationResult
  222. if (result.status === 'rejected') throw result.reason
  223. return result.value
  224. }
  225. /**
  226. * Fold titles for unique sessions from one cancellable corpus observation.
  227. *
  228. * Results preserve first-occurrence input order. Operational failures stay
  229. * isolated per session, while cancellation rejects the complete operation.
  230. * @param sessionIds - live or persisted session ids to observe.
  231. * @param signal - optional cancellation shared by all source reads.
  232. * @returns one fulfilled or rejected result per unique requested id.
  233. */
  234. async readTitleSnapshots(
  235. sessionIds: readonly SessionId[],
  236. signal?: AbortSignal,
  237. ): Promise<SessionTitleObservationResult[]> {
  238. return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => {
  239. const title = foldSessionTitle(source.events)
  240. return {
  241. session: structuredClone(source.header),
  242. ...title === undefined ? {} : { title },
  243. }
  244. }, signal)
  245. }
  246. /**
  247. * List lightweight raw-log event records for one logical session.
  248. * @param sessionId - live-preferred session id to read.
  249. * @returns event records in ascending seq order.
  250. */
  251. async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
  252. const loaded = await this._corpus.load(sessionId)
  253. return tracing.eventRecords(sessionId, loaded.events)
  254. }
  255. /**
  256. * Scan first-party semantic event documents with provider-independent filters.
  257. * @param sessionId - live-preferred session id to scan.
  258. * @param filters - ANDed metadata and literal-text predicates.
  259. * @returns matching semantic documents in ascending seq order.
  260. */
  261. async filterEvents(
  262. sessionId: SessionId,
  263. filters: readonly SessionEventResultFilter[],
  264. ): Promise<SessionEventSearchDocument[]> {
  265. const ownedFilters = materializeSessionEventResultFilters(filters)
  266. return this._filterEvents(sessionId, ownedFilters)
  267. }
  268. private async _filterSessions(
  269. filters: readonly SessionResultFilter[],
  270. signal?: AbortSignal,
  271. ): Promise<SessionRecord[]> {
  272. return filterSessionResults(await this._corpus.listSessions(signal), filters)
  273. }
  274. private async _filterEvents(
  275. sessionId: SessionId,
  276. filters: readonly SessionEventResultFilter[],
  277. ): Promise<SessionEventSearchDocument[]> {
  278. const loaded = await this._corpus.load(sessionId)
  279. const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
  280. return filterSessionEventDocuments(documents, filters)
  281. }
  282. /**
  283. * Read one session's complete current model surface from one corpus observation.
  284. * @param sessionId - live-preferred session id to read.
  285. * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.
  286. * @throws when source resolution fails or the session surface is invalid.
  287. */
  288. async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> {
  289. const loaded = await this._corpus.load(sessionId)
  290. return {
  291. session: structuredClone(loaded.header),
  292. inheritedEventCount: loaded.inheritedEventCount,
  293. capturedThroughSeq: loaded.events.at(-1)?.seq ?? null,
  294. events: tracing.currentSurfaceEvents(sessionId, loaded.events),
  295. }
  296. }
  297. /**
  298. * Trace known ancestry and descendants from one corpus observation.
  299. * @param sessionId - logical session id to trace.
  300. * @param signal - optional cancellation for persistence listing.
  301. * @returns a complete lineage or the first parent that could not be resolved.
  302. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
  303. */
  304. async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> {
  305. const records = await this._corpus.listSessions(signal)
  306. signal?.throwIfAborted()
  307. return tracing.traceSession(records, sessionId)
  308. }
  309. /**
  310. * Trace one event's direct positional replacements and cited source events.
  311. * @param request - target session id and event seq.
  312. * @param signal - optional cancellation for persisted source resolution.
  313. * @returns source header, direct links, and the target's positional replacement chain.
  314. * @throws when source resolution fails, the target is absent, or surface/source-event validation fails.
  315. */
  316. async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation> {
  317. const loaded = await this._corpus.load(request.sessionId, signal)
  318. signal?.throwIfAborted()
  319. return {
  320. session: loaded.header,
  321. ...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
  322. }
  323. }
  324. /**
  325. * Read one full event plus a bounded raw-log context window.
  326. * @param request - target session/seq and context sizes.
  327. * @param signal - optional cancellation for persisted source resolution.
  328. * @returns cloned target and neighboring events.
  329. */
  330. async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow> {
  331. const before = this._readWindow('before', request.before)
  332. const after = this._readWindow('after', request.after)
  333. const sessionId = request.sessionId
  334. const seq = request.seq
  335. return this._readEvent(sessionId, seq, before, after, signal)
  336. }
  337. private async _readEvent(
  338. sessionId: SessionId,
  339. seq: SessionSeqType,
  340. before: number,
  341. after: number,
  342. signal?: AbortSignal,
  343. ): Promise<SessionEventWindow> {
  344. const loaded = await this._corpus.load(sessionId, signal)
  345. signal?.throwIfAborted()
  346. const target = loaded.events[seq]
  347. if (target === undefined || target.seq !== seq) {
  348. throw new SessionQueryError(
  349. `session "${sessionId}" has no event at seq ${seq}`,
  350. 'SESSION_QUERY_EVENT_NOT_FOUND',
  351. )
  352. }
  353. const startSeq = SessionSeq(Math.max(0, seq - before))
  354. const endSeq = SessionSeq(Math.min(loaded.events.length - 1, seq + after))
  355. const targetSnapshot = snapshotSessionEvent(target)
  356. const events = loaded.events.slice(startSeq, endSeq + 1)
  357. .map(event => event === target
  358. ? targetSnapshot
  359. : snapshotSessionEvent(event))
  360. return {
  361. session: structuredClone(loaded.header),
  362. inheritedEventCount: loaded.inheritedEventCount,
  363. target: targetSnapshot,
  364. events,
  365. startSeq,
  366. endSeq,
  367. }
  368. }
  369. private _readWindow(name: 'before' | 'after', value: number | undefined): number {
  370. if (value === undefined) return 0
  371. if (!Number.isInteger(value) || value < 0 || value > this._readWindowMax) {
  372. throw new SessionQueryError(
  373. `${name} must be an integer between 0 and ${this._readWindowMax}`,
  374. 'SESSION_QUERY_INVALID_WINDOW',
  375. )
  376. }
  377. return value
  378. }
  379. }
  380. export default SessionQueryEngine