index.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. /**
  2. * Event-sourced session service: append-only session log, in-memory store, and
  3. * the derived LLM message history. Persistence is a plugin concern (subscribe
  4. * to `session/event`, drain on `session/flush`).
  5. *
  6. * @module @deepseek-ai/dsh-session
  7. */
  8. import { Context, Service } from 'cordis'
  9. import { isAbsolute } from 'node:path'
  10. import { deepFreeze } from '@deepseek-ai/dsh-llm'
  11. import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  12. import type { Scoped } from '@deepseek-ai/dsh-scope'
  13. import type { Message } from '@deepseek-ai/dsh-llm'
  14. import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
  15. import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
  16. import { snapshotJsonValue } from './json.ts'
  17. import { SurfaceManager } from './surface.ts'
  18. import type { SessionSurface } from './surface.ts'
  19. import { foldRequestHeader } from './request-header.ts'
  20. export * from './types.ts'
  21. export { isJsonValue, snapshotJsonValue } from './json.ts'
  22. export type { JsonValue } from './json.ts'
  23. export { interruptedTurnClosers } from './repair.ts'
  24. export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
  25. export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
  26. export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
  27. declare module 'cordis' {
  28. interface Context {
  29. sessions: SessionStore
  30. }
  31. interface Events {
  32. /**
  33. * Creation announcement during session publication. A synchronous throw vetoes and rolls
  34. * back with a paired disposal; detach requested during dispatch is deferred.
  35. * A returned-promise rejection is logged but cannot retroactively veto this
  36. * synchronous boundary.
  37. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
  38. * receive only sessions entered through that agent's context.
  39. * @param session - the session just entered and announced.
  40. * @dshScopeScan unsupported
  41. * @mode emit
  42. */
  43. 'session/created'(this: Scoped<Session>, session: Session): void
  44. /**
  45. * Emitted once when an announced session leaves the store, including
  46. * publication rollback, but never for an entry whose creation announcement
  47. * did not begin. Listener failures are logged and contained.
  48. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
  49. * @param session - the session that is no longer live in the store.
  50. * @dshScopeScan unsupported
  51. * @mode emit
  52. */
  53. 'session/disposed'(this: Scoped<Session>, session: Session): void
  54. /**
  55. * Post-commit, fire-and-forget append feed. The listener snapshot resolves
  56. * before the log push, but callbacks run after it; observer failures are
  57. * logged and contained without making the committed append fail.
  58. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
  59. * receive only events from sessions entered through that agent's context.
  60. * @param session - the session whose log grew.
  61. * @param event - the appended event, exactly as recorded.
  62. * @dshScopeScan unsupported
  63. * @mode emit
  64. */
  65. 'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
  66. /**
  67. * Awaited parallel durability checkpoint: every listener runs and the
  68. * caller awaits all of them, with no waterfall veto. Dispatch through
  69. * {@link SessionStore.flush}. Scope-filtered dispatch
  70. * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
  71. * @param session - the session whose buffered events must reach durable storage.
  72. * @dshScopeScan unsupported
  73. * @mode parallel
  74. */
  75. 'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
  76. }
  77. }
  78. /** Detach, validate, and freeze the creation metadata published by a session. */
  79. function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
  80. const input: unknown = source === undefined
  81. ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
  82. : source
  83. const snapshot = snapshotJsonValue(input)
  84. if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
  85. if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
  86. throw new Error('session header is not a plain JSON record')
  87. }
  88. const record = snapshot as Record<string, unknown>
  89. if (record.version !== SESSION_FORMAT_VERSION) {
  90. throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
  91. }
  92. if (record.id !== id) {
  93. throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
  94. }
  95. if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
  96. throw new Error('session header createdAt must be a finite number')
  97. }
  98. if (record.cwd !== undefined) {
  99. if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
  100. if (!isAbsolute(record.cwd)) {
  101. throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
  102. }
  103. }
  104. if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
  105. throw new Error('session header parentSession must be a string')
  106. }
  107. if (record.seedLength !== undefined
  108. && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
  109. throw new Error('session header seedLength must be a non-negative safe integer')
  110. }
  111. if (record.delegationDepth !== undefined
  112. && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
  113. throw new Error('session header delegationDepth must be a non-negative safe integer')
  114. }
  115. return deepFreeze(record as unknown as SessionHeader)
  116. }
  117. /** Validate the fixed event envelope after one-pass JSON materialization. */
  118. function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
  119. const event = value
  120. if (event['type'] === 'request/header-delta') {
  121. throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
  122. }
  123. const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
  124. if (Object.keys(event).some(key => !allowed.has(key))
  125. || !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
  126. || !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
  127. || !Number.isSafeInteger(event['seq']) || event['seq'] < 0
  128. || !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
  129. || !Number.isSafeInteger(event['time']) || event['time'] < 0
  130. || !Object.hasOwn(event, 'data')) {
  131. throw new Error(`seed event at index ${index} has an invalid event envelope`)
  132. }
  133. assertCurrentLlmShape(event, index)
  134. assertCurrentTurnEndShape(event, index)
  135. }
  136. /** Reject pre-provider request headers and assistant messages at the seed/load boundary. */
  137. function assertCurrentLlmShape(event: Record<string, unknown>, index: number): void {
  138. const data = event['data']
  139. if (typeof data !== 'object' || data === null) return
  140. const record = data as Record<string, unknown>
  141. if (event['type'] === 'request/header') {
  142. const header = record['header']
  143. const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
  144. if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
  145. }
  146. if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
  147. throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)
  148. }
  149. }
  150. /** Reject legacy aborted outcomes that persisted caller-owned reason detail. */
  151. function assertCurrentTurnEndShape(event: Record<string, unknown>, index: number): void {
  152. if (event['type'] !== 'turn/end') return
  153. const data = event['data']
  154. /* v8 ignore next -- this migration recognizes only the legacy object shape; format-wide payload validation is separate. */
  155. if (typeof data !== 'object' || data === null) return
  156. const reason = (data as Record<string, unknown>)['reason']
  157. /* v8 ignore next -- non-object reasons cannot carry the legacy aborted detail this migration removes. */
  158. if (typeof reason !== 'object' || reason === null || Array.isArray(reason)) return
  159. const record = reason as Record<string, unknown>
  160. if (record['kind'] === 'aborted'
  161. && (Object.keys(record).length !== 1 || !Object.hasOwn(record, 'kind'))) {
  162. throw new Error(`seed turn/end at index ${index} uses unsupported reason-bearing aborted format`)
  163. }
  164. }
  165. /** Whether an unknown value carries the current provider/model pair. */
  166. function hasProviderModel(value: unknown): boolean {
  167. if (typeof value !== 'object' || value === null) return false
  168. const pair = value as Record<string, unknown>
  169. return typeof pair['provider'] === 'string' && pair['provider'].length > 0
  170. && typeof pair['model'] === 'string' && pair['model'].length > 0
  171. }
  172. /** Reject request-header vocabulary removed with the legacy delta codec. */
  173. function assertSupportedRequestHeader(type: string, data: unknown, location: string): void {
  174. if (type === 'request/header-delta') {
  175. throw new Error(`${location} uses unsupported legacy request/header-delta format`)
  176. }
  177. if (type === 'request/header'
  178. && data !== null && typeof data === 'object' && !Array.isArray(data)
  179. && (data as Record<string, unknown>)['reason'] === 'fallback') {
  180. throw new Error(`${location} uses unsupported legacy request/header reason "fallback"`)
  181. }
  182. }
  183. type SessionCallback = (...args: unknown[]) => unknown
  184. /** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
  185. function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
  186. return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
  187. }
  188. /** Invoke one resolved observe-only listener snapshot with per-listener containment. */
  189. function invokeContainedSessionObservers(
  190. ctx: Context,
  191. name: 'session/event' | 'session/disposed',
  192. id: SessionId,
  193. args: unknown[],
  194. callbacks: SessionCallback[],
  195. ): void {
  196. for (const callback of callbacks) {
  197. try {
  198. const returned: unknown = callback(...args)
  199. void Promise.resolve(returned).catch((error: unknown) => {
  200. ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
  201. })
  202. } catch (error: unknown) {
  203. ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
  204. }
  205. }
  206. }
  207. /** All mutable lifecycle state for one exact store entry. */
  208. interface SessionEntry {
  209. readonly id: SessionId
  210. readonly session: Session
  211. readonly carrier: Scoped<Session>
  212. readonly emitCtx: Context
  213. announced: boolean
  214. announcing: boolean
  215. appending: boolean
  216. detachRequested: boolean
  217. detach(): void
  218. }
  219. /** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
  220. const attachments = new WeakMap<Session, SessionEntry>()
  221. /**
  222. * An event-sourced session: an append-only log of {@link SessionEvent}s.
  223. *
  224. * Plain class (not a Service) — create instances via `ctx.sessions.create()`.
  225. * Seeding with an existing event log replays/forks a session.
  226. */
  227. export class Session {
  228. private log: SessionEvent[] = []
  229. /** Single incremental owner of surface acceptance and projection state. */
  230. private readonly surfaceManager = new SurfaceManager(this.log)
  231. /** The ordered surface over this session's event log. */
  232. get surface(): SessionSurface {
  233. return this.surfaceManager
  234. }
  235. /**
  236. * Detached, deep-frozen creation metadata (format version, cwd, lineage,
  237. * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
  238. * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
  239. * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
  240. * `session.header` is always present. Kept out of the event log — it is a
  241. * storage concern, not replayable conversation state.
  242. */
  243. readonly header: SessionHeader
  244. /** The session identity, derived from its durable header's single copy. */
  245. get id(): SessionId {
  246. return this.header.id
  247. }
  248. constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
  249. if (seed) {
  250. // Validate the seed to the SAME invariants `append` enforces, so a
  251. // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
  252. // live log that no persistence backend could store: each event's `data`
  253. // must be JSON-serializable, and `seq` must be contiguous from 0 (the
  254. // `seq = log.length` contract the whole system relies on). Without this,
  255. // a bad seed would surface only later as a backend rejection or a silent
  256. // divergence between the live log and disk.
  257. for (const [index, source] of seed.entries()) {
  258. // The seed is a persistence/replay boundary: validate and detach the
  259. // complete event in one lossless-JSON pass.
  260. const snapshot = snapshotJsonValue(source)
  261. if (snapshot === undefined) {
  262. throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
  263. }
  264. assertSessionEventEnvelope(snapshot, index)
  265. assertSupportedRequestHeader(snapshot.type, snapshot.data, `seed event at index ${index}`)
  266. if (snapshot.seq !== index) {
  267. throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
  268. }
  269. // A seed is accepted incrementally through the same transition as a
  270. // live append and a full-log fold. The candidate is planned before it
  271. // enters `log`, so a failure cannot partially mutate the surface.
  272. try {
  273. this.surfaceManager.validateNext(snapshot)
  274. } catch (error: unknown) {
  275. throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
  276. }
  277. this.log.push(deepFreeze(snapshot))
  278. }
  279. }
  280. this.header = snapshotSessionHeader(id, header)
  281. }
  282. /** Cached immutable public snapshot of the private append-only log. */
  283. private eventsSnapshot: readonly SessionEvent[] | undefined
  284. /**
  285. * An immutable snapshot of the append-only event log. The snapshot is reused
  286. * until the next append; a previously returned array does not grow later.
  287. * Events and their nested data are deep-frozen at acceptance, so neither a
  288. * cast nor ordinary JavaScript can rewrite durable history.
  289. */
  290. get events(): readonly SessionEvent[] {
  291. this.eventsSnapshot ??= Object.freeze([...this.log])
  292. return this.eventsSnapshot
  293. }
  294. /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
  295. get seq(): number {
  296. return this.log.length
  297. }
  298. /**
  299. * Append one typed event to the log and synchronously notify observers via
  300. * the store-owned, module-private publication hooks. The hot path never blocks
  301. * on I/O — persistence plugins buffer asynchronously. Once the event enters
  302. * the log, the append is committed: observer failures are logged and
  303. * contained per listener, so they do not change the return value or prevent
  304. * later listeners from observing the same accepted event.
  305. *
  306. * @param type - The event type (key of {@link SessionEventMap}).
  307. * @param data - The event payload; must be JSON-serializable.
  308. * @param opts - Surface metadata: `surfaceOp` controls how the event enters
  309. * the ordered surface; `sourceEventSeqs` records provenance (the seq
  310. * numbers of events this one derives from). REQUIRED for
  311. * {@link SurfaceEventType} events (every message-producing event must
  312. * declare how it joins the surface, the sole source of derived history) and
  313. * rejected by the compiler for non-surface types like `turn/start` or
  314. * `assistant/chunk`.
  315. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
  316. * `data` that entered the log, so reading `event.data` back sees the logged
  317. * value, never the caller's still-mutable input.
  318. * @throws if `data` or surface metadata is not losslessly JSON-serializable
  319. * (BigInt, function, symbol, undefined, negative zero, non-finite number,
  320. * circular reference, sparse array, or an exotic object such as
  321. * Map/Set/Date/class instance), or when the candidate violates the
  322. * canonical surface contract (marker shape and eligibility, unique
  323. * earlier provenance, positional replacement validity, and complete
  324. * shadowed-node coverage). One recursive pass reads, validates, and
  325. * copies each nested value once, so a stateful getter cannot supply one value
  326. * to validation and another to storage. The event log is the durable source
  327. * of truth, so a bad event fails at the append site rather than later during
  328. * a backend flush. A synchronous internal dispatch validation failure or an
  329. * append reentered while this acceptance/publication boundary is open also
  330. * rejects before the log changes.
  331. */
  332. append<T extends SessionEventType>(
  333. type: T,
  334. data: SessionEventMap[T],
  335. ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
  336. ): SessionEvent<T> {
  337. const surfaceOpts: SurfaceIntent | undefined = opts[0]
  338. const surfaceMetadata = {
  339. ...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
  340. ...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
  341. }
  342. const dataSnapshot = snapshotJsonValue(data)
  343. if (dataSnapshot === undefined) {
  344. throw new Error(`session event "${type}" carries non-JSON-serializable data`)
  345. }
  346. assertSupportedRequestHeader(type, dataSnapshot, `session event "${type}"`)
  347. const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
  348. if (surfaceMetadataSnapshot === undefined) {
  349. throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
  350. }
  351. const entry = attachments.get(this)
  352. if (entry?.appending) {
  353. throw new Error('session append cannot reenter while another append is being published')
  354. }
  355. const event = deepFreeze({
  356. type,
  357. seq: this.log.length,
  358. time: Date.now(),
  359. data: dataSnapshot,
  360. ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
  361. } as unknown as SessionEvent<T>)
  362. this.surfaceManager.validateNext(event as SessionEvent)
  363. if (entry !== undefined) entry.appending = true
  364. try {
  365. let callbacks: SessionCallback[] | undefined
  366. const callbackArgs: unknown[] = [this, event]
  367. if (entry !== undefined) {
  368. callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
  369. }
  370. this.log.push(event as SessionEvent)
  371. this.eventsSnapshot = undefined
  372. if (callbacks !== undefined && entry !== undefined) {
  373. invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
  374. }
  375. return event
  376. } finally {
  377. if (entry !== undefined) {
  378. entry.appending = false
  379. if (entry.detachRequested && !entry.announcing) entry.detach()
  380. }
  381. }
  382. }
  383. /** Cached fold of the request-header events — see {@link requestHeader}. */
  384. private headerFold: EpochHeader | undefined
  385. /** Log position (events consumed) the header fold has reached. */
  386. private headerFoldSeq = 0
  387. /**
  388. * The {@link EpochHeader} in force after the log's last header event — the
  389. * header the NEXT request will be compared against — or undefined before
  390. * the first `request/header` snapshot. The live, incrementally-maintained
  391. * form of `foldRequestHeader(session.events)`: each header event is folded
  392. * once, when first seen, so a per-step read costs O(new events).
  393. * @returns the folded header, or undefined when no header event exists yet.
  394. */
  395. requestHeader(): EpochHeader | undefined {
  396. if (this.headerFoldSeq < this.log.length) {
  397. // Frozen on update: the fold is session state exposed by reference — a
  398. // consumer mutating it in place (instead of building a replacement)
  399. // would desync every later comparison against the log, so mutation
  400. // throws instead.
  401. this.headerFold = deepFreeze(foldRequestHeader(this.log.slice(this.headerFoldSeq), this.headerFold))
  402. this.headerFoldSeq = this.log.length
  403. }
  404. return this.headerFold
  405. }
  406. /** The derived-message cache: frozen projections, extended per unseen node. */
  407. private derived: Message[] = []
  408. /** Surface position (nodes projected) the cache has reached. */
  409. private derivedNodes = 0
  410. /** {@link SurfaceManager.replaceGeneration} the cache was built under. */
  411. private derivedGeneration = 0
  412. /**
  413. * Derive the LLM message history by walking the ordered sequences of
  414. * message-producing events maintained by `surfaceOp` markers. The
  415. * surface is the single source of derived history: every message-producing
  416. * append records its `surfaceOp`, so a raw event with no marker (a chunk, a
  417. * turn boundary) is correctly absent, and a compaction `replace` deletes the
  418. * shadowed nodes from the derivation. The projection rules are
  419. * {@link deriveEventMessage}, folded per node.
  420. *
  421. * CACHED: each surface node is projected exactly once, when first seen — a
  422. * call costs O(new nodes), and a surface rewrite (a `replace`;
  423. * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is
  424. * a fresh snapshot per call (later appends never grow an array a caller
  425. * already holds); the `Message` objects in it are SHARED and **deep-frozen**.
  426. * Their content reuses the already frozen durable event data, so the cache
  427. * needs no second deep clone and consumers still cannot mutate the log.
  428. * @returns a fresh array of the shared, frozen derived history.
  429. */
  430. deriveMessages(): Message[] {
  431. const surface = this.surface
  432. const nodes = surface.nodes
  433. const generation = surface.replaceGeneration
  434. if (generation !== this.derivedGeneration) {
  435. this.derived = []
  436. this.derivedNodes = 0
  437. this.derivedGeneration = generation
  438. }
  439. for (const seq of nodes.slice(this.derivedNodes)) {
  440. // Surface sequences are built from this.log — seq is always a valid
  441. // index by construction. The non-null assertion expresses that invariant.
  442. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  443. const msg = this.deriveEventMessage(this.log[seq]!)
  444. // A surface node is one of the five message-producing types, but an
  445. // empty-content assistant/message (a max-tokens step that hosts only
  446. // usage) derives to null and must not enter the transcript.
  447. if (msg) this.derived.push(deepFreeze(msg))
  448. }
  449. this.derivedNodes = nodes.length
  450. return [...this.derived]
  451. }
  452. /**
  453. * Project a single event into the LLM message it derives to, or null when
  454. * it produces none — a non-surface event (chunk, boundary, log-only record)
  455. * or an empty-content assistant/message (which exists only to host usage).
  456. * The per-node pure function {@link deriveMessages} folds over the surface;
  457. * an external reconstructor (or the dev invariant) folds the same function
  458. * over a log prefix's surface to rebuild the exact messages any request was
  459. * built from (the reconstructability Agent Note). The returned message wrapper is
  460. * fresh; its content reuses the logged event's already deep-frozen durable
  461. * data, so changing the wrapper cannot rewrite the log and changing content
  462. * throws.
  463. * @param event - the event to project.
  464. * @returns the derived message, or null when the event produces none.
  465. */
  466. deriveEventMessage(event: SessionEvent): Message | null {
  467. // Intentionally non-exhaustive: only message-producing events derive
  468. // history; turn/step boundaries, chunks, usage, and errors are
  469. // trace/replay data.
  470. switch (event.type) {
  471. // Injected context and mid-turn steering project identically to a user
  472. // prompt: content verbatim, in user role. context's `source`/`meta` and
  473. // steering's `turn` are log-only and do not reach the model. Do NOT
  474. // re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
  475. // caller-owned — a producer bakes it into `content`, as workspace-context
  476. // does with `<system-reminder>` — or, if reintroduced, must be driven by
  477. // the event `meta` map and a dedicated renderer, keeping this projection a
  478. // verbatim pass-through. See the deferred design note in
  479. // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
  480. case 'user/message':
  481. case 'context/message':
  482. case 'steering/message': {
  483. return { role: 'user', content: event.data.content }
  484. }
  485. case 'assistant/message': {
  486. // Skip an empty-content assistant/message: it exists only to host a
  487. // max-tokens step's usage and must not inject a content-less assistant
  488. // turn into the provider transcript.
  489. if (event.data.content.length === 0) return null
  490. return { role: 'assistant', content: event.data.content, provenance: event.data.provenance }
  491. }
  492. case 'tool/result': {
  493. const { callId, content, isError } = event.data
  494. return {
  495. role: 'user',
  496. content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
  497. }
  498. }
  499. default:
  500. // A non-surface event (boundary, chunk, log-only record) projects to
  501. // no message. Merge-extensible union: no assertNever here.
  502. return null
  503. }
  504. }
  505. }
  506. /** A fork source: either the live session object or its live store id. */
  507. export type SessionForkSource = Session | SessionId
  508. /**
  509. * Rejection codes for session forking: the fork source id is unknown to the
  510. * live store (`SESSION_NOT_FOUND`) or names a session object that is not the
  511. * store's live instance (`SESSION_NOT_LIVE`); the requested child id is
  512. * already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
  513. * existing seq (`INVALID_BOUNDARY`); or the boundary event is not a
  514. * `turn/end` — a fork must cut on a closed turn (`OPEN_TURN`).
  515. */
  516. export type SessionForkErrorCode =
  517. | 'SESSION_NOT_FOUND'
  518. | 'SESSION_NOT_LIVE'
  519. | 'SESSION_ALREADY_EXISTS'
  520. | 'INVALID_BOUNDARY'
  521. | 'OPEN_TURN'
  522. /** Typed error for session fork rejections. */
  523. export class SessionForkError extends Error {
  524. constructor(message: string, public readonly code: SessionForkErrorCode) {
  525. super(message)
  526. this.name = 'SessionForkError'
  527. }
  528. }
  529. /**
  530. * In-memory session store (`ctx.sessions`).
  531. *
  532. * Persistence is intentionally not implemented here — persistence plugins
  533. * subscribe to `session/event` and flush on `session/flush` / dispose.
  534. */
  535. export class SessionStore extends Service {
  536. private store = new Map<SessionId, SessionEntry>()
  537. private counter = 0
  538. constructor(ctx: Context) {
  539. super(ctx, 'sessions')
  540. }
  541. /**
  542. * Create a session owned by the calling fiber: disposing that fiber stops
  543. * event notification and removes the session from the store. `options.seed`
  544. * populates the session with a copy of those events (replay/fork);
  545. * `options.meta` attaches creation metadata (validated absolute `cwd`, seed
  546. * and parent lineage, and delegation depth) as the immutable
  547. * {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
  548. *
  549. * For an agent whose session must be torn down IN ORDER with its loop (so the
  550. * loop's final flush is captured before the store attachment ends), do NOT use this
  551. * — fold the session lifecycle into the agent's own effect via
  552. * {@link prepare} + {@link enter} + {@link announce} (see
  553. * `dsh-agent-loop`'s creation transaction).
  554. *
  555. * @param id - the session id; omitted, the store mints `session-<n>`.
  556. * @param options - seed events and/or creation metadata for the header.
  557. * @returns the live session, already entered and announced.
  558. * @throws if a session with `id` already exists, metadata is not a plain
  559. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  560. * non-absolute path (storage backends key directories off it).
  561. */
  562. create(id?: SessionId, options?: CreateSessionOptions): Session {
  563. const session = this.prepare(id, options)
  564. // Single effect owned by the calling fiber. Yield the detach BEFORE
  565. // announcing so a throwing `session/created` listener rolls the attach back
  566. // (the generator effect disposes already-yielded disposers on a throw)
  567. // instead of leaking the store entry and its publication hooks.
  568. this.ctx.effect(function* (this: SessionStore) {
  569. yield this.enter(session)
  570. this.announce(session)
  571. }.bind(this), 'sessions.create()')
  572. return session
  573. }
  574. /**
  575. * Build a session WITHOUT entering it into the store — validate the id/cwd and
  576. * construct the {@link Session} (with its immutable {@link SessionHeader}).
  577. * Pairs with {@link enter} + {@link announce}: a caller that owns a composite
  578. * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
  579. * effect so a fiber unload tears the session + agent down as a single ORDERED
  580. * chain rather than as racing sibling effects — which would remove the publication hooks
  581. * before the loop's closing `session/flush`, dropping the closing events.
  582. *
  583. * @param id - the session id; omitted, the store mints `session-<n>`.
  584. * @param options - seed events and/or creation metadata for the header.
  585. * @returns the constructed session, NOT yet in the store.
  586. * @throws if a session with `id` already exists, metadata is not a plain
  587. * lossless-JSON record with valid scalar fields, or `meta.cwd` is a
  588. * non-absolute path.
  589. */
  590. prepare(id?: SessionId, options?: CreateSessionOptions): Session {
  591. let sessionId: SessionId
  592. if (id === undefined) {
  593. do sessionId = SessionId(`session-${++this.counter}`)
  594. while (this.store.has(sessionId))
  595. } else {
  596. sessionId = SessionId(id)
  597. }
  598. if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
  599. const seed = options?.seed
  600. const meta = options?.meta
  601. const header: SessionHeader = {
  602. version: SESSION_FORMAT_VERSION,
  603. id: sessionId,
  604. createdAt: meta?.createdAt ?? Date.now(),
  605. ...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
  606. ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
  607. ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
  608. ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
  609. }
  610. return new Session(sessionId, seed, header)
  611. }
  612. /**
  613. * Enter a {@link prepare}d session into the store: install the module-private
  614. * append publication hooks and add it to the store. Returns the DETACH
  615. * disposer (hooks + store removal). Does NOT emit `session/created` —
  616. * the caller yields this disposer inside its effect and THEN calls
  617. * {@link announce}, so a throwing `session/created` listener rolls the attach
  618. * back instead of leaking it.
  619. *
  620. * Re-checks the id for a duplicate: `prepare` and `enter` are public
  621. * cross-package primitives and a caller may interleave arbitrary work (or
  622. * another create) between them, so a stale prepared session must NOT overwrite
  623. * a live store entry of the same id — its detach disposer would later delete
  624. * the REAL session. The {@link create} convenience and the agent factory call
  625. * the two back-to-back so they never trip this, but the public seam cannot
  626. * assume that.
  627. *
  628. * @param session - a {@link prepare}d session not yet in the store.
  629. * @returns the detach disposer (publication hooks + store removal). When called from
  630. * a synchronous `session/created` listener, removal and disposal wait until
  631. * that creation dispatch unwinds.
  632. * @throws if a session with this id is already in the store.
  633. */
  634. enter(session: Session): () => void {
  635. const id = session.id
  636. const carrier = scopeTarget(session, scopeOf(this.ctx))
  637. // This is the authoritative collision boundary after arbitrary unpublished
  638. // preparation. Only one exact same-id transaction can publish.
  639. if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
  640. if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
  641. const entry: SessionEntry = {
  642. id,
  643. session,
  644. carrier,
  645. emitCtx: this.ctx,
  646. announced: false,
  647. announcing: false,
  648. appending: false,
  649. detachRequested: false,
  650. detach: () => { this.detachEntered(entry) },
  651. }
  652. this.store.set(id, entry)
  653. attachments.set(session, entry)
  654. let entered = true
  655. const detach = (): void => {
  656. if (!entered) return
  657. entered = false
  658. // A lifecycle listener may own the advanced detach capability. Keep the
  659. // entry and its publication hooks live until synchronous creation or append
  660. // publication unwinds, then publish the paired disposal edge.
  661. if (entry.announcing || entry.appending) {
  662. entry.detachRequested = true
  663. return
  664. }
  665. entry.detach()
  666. }
  667. return detach
  668. }
  669. /** Remove one exact entered session and emit its paired disposal when announced. */
  670. private detachEntered(entry: SessionEntry): void {
  671. entry.detachRequested = false
  672. // A stale capability cannot remove observers or storage belonging to a
  673. // later same-id lifecycle.
  674. /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
  675. if (this.store.get(entry.id) !== entry) return
  676. this.store.delete(entry.id)
  677. attachments.delete(entry.session)
  678. if (entry.announced) this.emitDisposed(entry)
  679. }
  680. /** Emit `session/created` exactly once for an {@link enter}ed session (with
  681. * the carrier {@link enter} captured). Separate from {@link enter} so the
  682. * caller can yield the detach disposer first (rollback safety — see
  683. * {@link enter}).
  684. * @param session - the entered session to announce to listeners.
  685. * @throws if the session is not live or its announcement already began,
  686. * including a reentrant call from a creation listener. */
  687. announce(session: Session): void {
  688. const entry = this.liveEntryFor(session)
  689. if (entry.announced || entry.announcing) {
  690. throw new Error(`session "${entry.id}" was already announced`)
  691. }
  692. // Mark before emit: Cordis emit may deliver to earlier listeners and then
  693. // throw. Rollback must still pair that partial creation with disposal, and
  694. // a listener cannot recursively create a second lifecycle edge.
  695. entry.announced = true
  696. const callbackArgs: unknown[] = [session]
  697. entry.announcing = true
  698. try {
  699. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
  700. for (const callback of callbacks) {
  701. // Synchronous throws intentionally propagate and veto publication; the
  702. // yielded detach then emits the paired disposal edge. An async function
  703. // is nevertheless assignable to a void listener, so observe its returned
  704. // promise: rejection is too late to roll back and must be logged instead
  705. // of becoming unhandled.
  706. const returned: unknown = callback(...callbackArgs)
  707. void Promise.resolve(returned).catch((error: unknown) => {
  708. this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
  709. })
  710. }
  711. } finally {
  712. entry.announcing = false
  713. if (entry.detachRequested && !entry.appending) entry.detach()
  714. }
  715. }
  716. /** Emit the paired teardown notification with per-listener containment. */
  717. private emitDisposed(entry: SessionEntry): void {
  718. const callbackArgs: unknown[] = [entry.session]
  719. try {
  720. const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
  721. invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
  722. } catch (error: unknown) {
  723. this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
  724. }
  725. }
  726. /**
  727. * Dispatch the awaited `session/flush` durability checkpoint for `session`,
  728. * with the carrier captured at {@link enter}. THE flush entry point: the
  729. * store owns the carrier, so callers (the loop's turn-end checkpoint, idle
  730. * injection, teardown drains) must come through here rather than dispatch a
  731. * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
  732. * scoped-dispatch invariant can pin it.
  733. * @param session - the session whose buffered events must reach durable storage.
  734. * @returns resolves when every flush listener has settled; after all settle,
  735. * rejects with the first registered listener failure if any listener failed.
  736. */
  737. async flush(session: Session): Promise<void> {
  738. const { carrier } = this.liveEntryFor(session)
  739. const callbackArgs: unknown[] = [session]
  740. const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
  741. const results = await Promise.allSettled(callbacks.map((callback) => {
  742. try {
  743. return callback(...callbackArgs)
  744. } catch (error: unknown) {
  745. // Preserve the listener's exact rejection value; flush is a caller-owned
  746. // failure boundary, and Cordis listeners may throw arbitrary values.
  747. // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
  748. return Promise.reject(error)
  749. }
  750. }))
  751. const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
  752. if (failure !== undefined) throw failure.reason
  753. }
  754. /** Return the exact live entry; detached/prepared objects reject. */
  755. private liveEntryFor(session: Session): SessionEntry {
  756. const entry = attachments.get(session)
  757. if (entry === undefined || this.store.get(entry.id) !== entry) {
  758. throw new Error(`session "${session.id}" is not live in this store`)
  759. }
  760. return entry
  761. }
  762. /**
  763. * Look up a live session.
  764. * @param id - the session id to look up.
  765. * @returns the session, or undefined when no live session has that id.
  766. */
  767. get(id: SessionId): Session | undefined {
  768. return this.store.get(id)?.session
  769. }
  770. /**
  771. * All live sessions, in creation order.
  772. * @returns a fresh array; mutating it does not affect the store.
  773. */
  774. list(): Session[] {
  775. return [...this.store.values()].map(entry => entry.session)
  776. }
  777. /**
  778. * Create a live child session from a turn-enclosed prefix of a live source.
  779. * `boundary` is an inclusive source event seq; omitted means the source's
  780. * current last event. A non-empty selected slice must end at `turn/end`.
  781. *
  782. * @param source - Live source session object or id.
  783. * @param boundary - Inclusive source event seq to fork through; omitted means
  784. * the source's current last event, and omitted on an empty source forks an
  785. * empty child.
  786. * @param childSessionId - Optional child session id; omitted delegates to
  787. * `SessionStore`'s id policy.
  788. * @returns The created live child session.
  789. */
  790. fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session {
  791. if (childSessionId !== undefined && this.get(childSessionId) !== undefined) {
  792. throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
  793. }
  794. const liveSource = this._resolveForkSource(source)
  795. const seed = this._forkSeed(liveSource, boundary)
  796. return this.create(childSessionId, {
  797. seed,
  798. meta: {
  799. ...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
  800. parentSession: liveSource.id,
  801. seedLength: seed.length,
  802. },
  803. })
  804. }
  805. private _forkSeed(session: Session, requestedBoundary: number | undefined): SessionEvent[] {
  806. const events = session.events
  807. const lastEvent = events.at(-1)
  808. let boundary: number
  809. if (requestedBoundary !== undefined) {
  810. boundary = requestedBoundary
  811. } else {
  812. if (lastEvent === undefined) return []
  813. boundary = lastEvent.seq
  814. }
  815. if (!Number.isSafeInteger(boundary) || boundary < 0) {
  816. throw new SessionForkError(
  817. `fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
  818. 'INVALID_BOUNDARY',
  819. )
  820. }
  821. if (boundary >= events.length) {
  822. const lastSeq = events.at(-1)?.seq
  823. throw new SessionForkError(
  824. `fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
  825. 'INVALID_BOUNDARY',
  826. )
  827. }
  828. const boundaryEvent = events[boundary]
  829. if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
  830. throw new SessionForkError(
  831. `fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
  832. 'INVALID_BOUNDARY',
  833. )
  834. }
  835. if (boundaryEvent.type !== 'turn/end') {
  836. throw new SessionForkError(
  837. `fork boundary ${boundary} in session "${session.id}" must be turn/end, got ${boundaryEvent.type}`,
  838. 'OPEN_TURN',
  839. )
  840. }
  841. return events.slice(0, boundary + 1)
  842. }
  843. private _resolveForkSource(source: SessionForkSource): Session {
  844. if (typeof source === 'string') {
  845. const session = this.get(source)
  846. if (session === undefined) throw new SessionForkError(`session "${source}" not found`, 'SESSION_NOT_FOUND')
  847. return session
  848. }
  849. const live = this.get(source.id)
  850. if (live === undefined) {
  851. throw new SessionForkError(`session "${source.id}" not found`, 'SESSION_NOT_FOUND')
  852. }
  853. if (live !== source) throw new SessionForkError(`session "${source.id}" is not the live store instance`, 'SESSION_NOT_LIVE')
  854. return source
  855. }
  856. }
  857. export default SessionStore